Skip to content

Compare the caller's process, not its thread, to the client's - #55

Merged
dragotin merged 1 commit into
opencloud-eu:mainfrom
Ebrathul:fix/hydration-bypass-uses-thread-id
Aug 24, 2026
Merged

Compare the caller's process, not its thread, to the client's#55
dragotin merged 1 commit into
opencloud-eu:mainfrom
Ebrathul:fix/hydration-bypass-uses-thread-id

Conversation

@Ebrathul

Copy link
Copy Markdown
Contributor

openVFSfuse_open() exempts the desktop client from hydration blocking so that
the client can write the content it just downloaded without that write being
treated as a foreign access that needs hydrating. The check is:

if (fuse_get_context()->pid == _jobs.desktopClientPid()) {

fuse_context::pid is the calling thread id. desktopClientPid() is the
process id the client announced in its VERSION reply. The two are equal
only when the client happens to do the write from its main thread.

A sync client downloads on worker threads, so in practice the exemption never
fires. The client's own hydration write is queued as a fresh hydration request,
which the client answers with another write, and open() deadlocks against the
hydration it is currently serving.

This is not specific to the client I was testing with: any client that does its
transfers off the main thread hits it.

Reproduction

A stub client on the socket API, driving a real FUSE mount over a tree of
dehydrated placeholders, with the stub serving each V2/HYDRATE_FILE on its own
thread — which is what a real client does.

stub process id announced over VERSION:  54377
pids FUSE reported for the write threads: 54449 54546 54550 54553 54557 ...
all present under /proc/54377/task/       -> thread ids, not the tgid
"bypassing" log lines during the run:     0

Eight concurrent opens of dehydrated placeholders wedged the mount until
hydrationTimeoutSeconds expired, and not one bypassing line was logged.

With this change: eight bypassing lines and eight hydration requests, all
eight opens complete, every checksum matches the source, and the small files no
longer queue behind the 500 MB transfer.

The change

A thread of the client is visible as /proc/<pid>/task/<tid>, so a single
stat() answers the question with nothing to read or parse. The direct
comparison stays as a fast path in front of it.

This check runs on every open() in the sync root, so its cost matters. It is
one stat() on a path that is already in the dentry cache, and it sits next to
getcallername(), which already does a readlink("/proc/<pid>/exe") on the same
code path — so this does not add a /proc access where there was none, it adds
a cheaper one alongside an existing one. Parsing /proc/<pid>/status for Tgid:
would work too and was my first attempt, but it means opening and reading a file
per open() where a stat() will do.

On platforms without /proc only the direct comparison applies, which leaves
them on exactly the previous behaviour.

What I could not do

macOS has the same bug and this does not fix it there. The equivalent is
proc_pidinfo() with PROC_PIDTBSDINFO, whose pbi_pid gives the process id
for a thread. I have no macOS machine to test that on, so I left the platform on
its current behaviour rather than commit an untested path. Happy to add it if
someone can verify it.

There is also a race I did not try to close: a thread can exit between the FUSE
request and the stat(), and a pid can in principle be recycled. Both windows
existed before this change — getcallername() has the same exposure — and
closing them properly needs pidfds, which felt out of scope for a bug fix.

I also did not add a test. Reproducing this needs a client that writes from a
worker thread and a real mount; socketthreadtest (from #54) exercises the
socket layer, but the identity check lives on the FUSE side of the boundary and
I did not see a clean way to reach it from there. Suggestions welcome.

Relationship to #54

Independent of it. The line this touches exists on main and #54 does not
modify it — its hunks are on either side. This applies cleanly to main on its
own, and rebases onto #54 without conflict. I found it while testing #54's
branch, which is the only reason the two are mentioned together.

Environment

Linux 7.1.8 (CachyOS), gcc 16.2.1, libfuse 3.18.2, Btrfs. Built
RelWithDebInfo; ctest green.

openVFSfuse_open() exempts the desktop client from hydration blocking so
that it can write the content it just downloaded without that write
being treated as a foreign access needing hydration. The check compared
fuse_get_context()->pid against the pid the client announced over the
socket API's VERSION message.

fuse_context::pid is the calling *thread* id, while the client announces
its process id. The two are equal only when the client writes from its
main thread. A sync client downloads on worker threads, so in practice
the exemption never fires: the client's own write is queued as a fresh
hydration request, which the client answers with another write, and
open() deadlocks against the hydration it is serving.

A thread of the client is visible as /proc/<pid>/task/<tid>, so a single
stat() answers this with nothing to read or parse. The direct comparison
is kept as a fast path in front of it, and is the only check on platforms
without /proc, which leaves those on the previous behaviour.

Verified against a stub client on a real FUSE mount, on Linux, with the
stub serving each hydration on its own thread the way a real client
does. Before: eight concurrent opens of dehydrated placeholders wedge
the mount until hydrationTimeoutSeconds expires, and not one "bypassing"
line is logged. After: eight bypasses and eight hydration requests are
logged, and all eight complete with checksums matching the source.

macOS has the same bug. The equivalent there is proc_pidinfo() with
PROC_PIDTBSDINFO, whose pbi_pid gives the process id for a thread. I
have no macOS machine to test that on, so I left the platform on its
current behaviour rather than commit an untested path.

@dragotin dragotin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, thank you!

@dragotin
dragotin merged commit 9664ea1 into opencloud-eu:main Aug 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants