Skip to content

Fix hydration race, socket framing, and cross-desktop ignore defaults - #54

Closed
toxicphreAK wants to merge 6 commits into
opencloud-eu:mainfrom
toxicphreAK:fix/integration-findings
Closed

Fix hydration race, socket framing, and cross-desktop ignore defaults#54
toxicphreAK wants to merge 6 commits into
opencloud-eu:mainfrom
toxicphreAK:fix/integration-findings

Conversation

@toxicphreAK

@toxicphreAK toxicphreAK commented Aug 18, 2026

Copy link
Copy Markdown

Six fixes found while integrating openVFS into the Nextcloud desktop client. One commit per issue; they can be taken separately.

Hydration path (openvfsfuse.cpp, sharedmap.*)

  • open() intermittently returned ENOENT for files that exist. The job was registered by the socket thread after the send succeeded, while open() started polling 10 ms after PostMsg() — which only queues. Miss that window and absence-from-map was read as failure. The job is now inserted before the message is posted, and a send failure is published as an explicit Failed state rather than inferred from a missing entry.
  • The backoff had no wall-clock ceiling: it grew by roughly the golden ratio per step, so MaxCnt = 20 bounded the number of polls, not the time. By iteration 9 a single sleep was ~36 s, and a wedged client left open() in uninterruptible sleep indefinitely. SharedMap now carries a condition variable; waitForJob() blocks on it with a deadline, configurable via hydrationTimeoutSeconds (default 300).
  • Failure returns are now EIO (client reported an error) and ETIMEDOUT (no answer). ENOENT reads as "the file is gone" and sends people looking in the wrong place.

Protocol (socketthread.cpp)

  • readSocket() read 1023 bytes with no reassembly. On a SOCK_STREAM, a reply longer than the buffer — or merely split across two reads — was parsed as two messages, both failed, and the reply was lost. The desync was self-sustaining, so one long arguments.error string broke hydration until restart. A persistent buffer now dispatches only complete newline-terminated messages and keeps the remainder.

Defaults and diagnostics

  • ignoreApps was KDE-only, so on GNOME/Cinnamon/MATE/Xfce nothing was blocked: browsing a folder downloaded everything in it, indexing downloaded the sync root. The default now covers the common file managers, the freedesktop thumbnailer convention, tracker/localsearch, gvfs helpers, updatedb and ClamAV.
  • name(PinStates) returned the name(States) strings, inverted — OnlineOnly printed as "hydrated", AlwaysLocal as "dehydrated". Its only caller is openvfs_stat, the tool integrators reach for when pin-state handling misbehaves.
  • SPDX: src/openvfs/ gave three different licensing answers for one library, and libopenvfs is what downstream clients link against. Aligned to GPL-3.0-or-later, plus the three missing headers.

Tests

ctest was wired up but had no tests, so the existing "Run tests" CI step had nothing to run. socketthreadtest stands in for the desktop client on a real AF_UNIX socket and drives the actual SocketThread/SharedMap: split messages, an 8 KB reply, batched replies, stream resync, timeout bounded by wall clock, prompt pickup of a late reply, and PostMsg reporting a drop during shutdown. Verified to fail against the pre-fix framing code; clean under ThreadSanitizer.

Separately, and not part of the diff, the whole path was exercised against a real FUSE mount with a stub client on the socket API, opening an actual dehydrated placeholder:

scenario result
client hydrates open() succeeds, 4096 bytes, 0.27 s
client replies with an 8 KB arguments.error EIO in 0.26 s (the old 1023-byte read desynced the stream here)
client never answers, hydrationTimeoutSeconds: 3 ETIMEDOUT at 3.08 s
caller matches ignoreApps EPERM, and zero hydration requests reach the client
caller does not match one request, hydration succeeds

What I could not verify

  • The ignoreApps list was checked against the thumbnailers actually installed on my machine (GNOME): all four /usr/share/thumbnailers entries are covered, including gnome-thumbnail-font, which does not match the -thumbnailer convention. The KDE, Xfce, Cinnamon and MATE entries are from documentation and are unverified against a live install — worth a second pair of eyes from someone running those.
  • The open() registration race is fixed by construction: inserting the job before posting makes the window unobservable. I did not build a test that reproduces the original failure, since it depends on losing a scheduler race.
  • name(PinStates) is covered by inspection only; there is no test for openvfs_stat output.

Deliberately left to you

  • ignoreApps stays a deny-list. Enumerating every indexer, thumbnailer and antivirus does not converge; a permit-list of what should trigger a download is a smaller, more stable set — but that is a design decision.
  • A blocked caller still gets EPERM. Whether ENODATA or serving placeholder content behaves better across thumbnailers has real tradeoffs.
  • socketthread.{h,cpp} keep no SPDX tag: they carry third-party MIT code alongside the GPL-3 notice, and pinning one expression on that is yours to decide. A REUSE.toml would make the tree checkable with reuse lint.

Incidental

_transfer_id is now std::atomic — FUSE dispatches from several threads and it was incremented unguarded, so two concurrent opens could share one job entry. Config parsing tolerates missing keys, so an older config file keeps working.

Each finding is written up in detail in the commit messages. Happy to split this into separate PRs, or to file the findings as issues here first, if that suits your workflow better.

name(PinStates) returned the strings belonging to name(States) for its
first two cases, and inverted at that: OnlineOnly printed as "hydrated"
and AlwaysLocal as "dehydrated".

The only caller is the openvfs_stat diagnostic tool, which prints state
and pin state side by side -- so the tool integrators reach for when pin
state handling misbehaves reported the exact opposite of the truth. The
two vocabularies are now distinguishable at a glance as well.

Fixes #4
readSocket() read at most 1023 bytes per call and left message
reassembly to a FIXME. The socket is a SOCK_STREAM and carries no
message boundaries, so any reply longer than the buffer -- or merely
split across two reads by the kernel -- was parsed as two independent
messages. Both halves then failed to parse, the original reply was lost,
and the waiting open() sat out its full backoff.

Worse, the effect is self-sustaining: once the stream desynchronises
every subsequent message on the connection is misparsed, so a single
long reply broke hydration until restart. A V2/HYDRATE_FILE_RESULT
carrying a path plus a free-form arguments.error string clears 1 KB
without trying.

processSocketInput() now drains the socket into a persistent buffer,
dispatches only complete newline terminated messages, and keeps the
remainder for the next read. handleReceivedMsg() correspondingly handles
a single message and no longer splits on newlines itself, which also
retires its trailing-empty-fragment case. The buffer is bounded so a
peer that never sends a newline cannot grow it without limit.

Fixes #2
Two problems in the wait loop of openVFSfuse_open(), both on the hottest
path in the project.

First, the job was registered by the socket thread only after the send
succeeded, while open() started polling the map 10 ms after PostMsg() --
which merely queues. If the socket thread had not been scheduled and had
not completed its write() within that window, the lookup missed, and
absence-from-map was read as failure: open() returned ENOENT for a file
that plainly exists and was in the middle of being hydrated. That is the
"the first open fails, the second works" flakiness.

The job is now inserted before the message is posted, so no waiter can
observe the absence of a job it just posted, and a send failure is
published explicitly as a Failed state instead of being inferred from a
missing entry -- two conditions the old code collapsed into one.

Second, the backoff grew by roughly the golden ratio per step with no
wall-clock ceiling: MaxCnt of 20 bounded the number of polls, not the
time. By iteration 9 a single sleep was ~36 s, and a wedged client left
open() blocked in uninterruptible sleep for what is effectively forever,
with the calling application unkillable. Late replies also waited out a
whole sleep interval before being noticed, so latency was dominated by
poll granularity rather than by the download.

SharedMap now carries a condition variable and SharedMap::waitForJob()
blocks on it with a deadline: a result is observed the moment the socket
thread publishes it, and the wait is bounded in seconds rather than in
polls. The timeout is configurable via hydrationTimeoutSeconds, since a
large file over a slow link is legitimately slow while an unresponsive
client is not.

The failure returns are now honest as well: EIO when the client reports
an error, ETIMEDOUT when it does not answer. Applications and users both
read ENOENT as "the file is gone", which sent us looking in the wrong
place.

Also made _transfer_id atomic. FUSE dispatches from several threads and
the counter was incremented unguarded, so two concurrent opens could be
handed the same id and share one job entry -- the same class of bug,
noticed while fixing the above. Config parsing now tolerates missing
keys so that an older config file keeps working.

Fixes #1
Fixes #3
The shipped config blocked only KDE components from triggering a
hydration, so on GNOME, Cinnamon, MATE or Xfce nothing was blocked at
all: nautilus and its thumbnailer helpers, tracker/localsearch, gvfsd
and the shell search providers open dehydrated files as a matter of
routine. Browsing a folder downloaded everything in it and indexing
downloaded the entire sync root -- silently, and on what is probably the
most common Linux desktop.

The list now covers the common file managers, the freedesktop
thumbnailer convention (a "thumbnailer" suffix catches the -thumbnailer
binaries and ffmpegthumbnailer alike), the GNOME/tracker and localsearch
indexers, gvfs helpers, locate's updatedb and ClamAV.

/usr/bin/dolphin moved from byName to an endsWith entry: matching the
binary name rather than an absolute path holds across distributions that
install elsewhere.

This is a starting point rather than an exhaustive list -- see
INTEGRATION.md. Enumerating every indexer, thumbnailer and antivirus in
existence does not converge, so a permit-list of the few applications
that should be allowed to trigger a download may scale better; that is a
design decision for the maintainers and is deliberately not attempted
here. The errno returned to a blocked caller is likewise left at EPERM.

Fixes #5
src/openvfs/ is one library in one directory but gave three different
licensing answers: four files GPL-3.0-or-later, openvfs.cpp
GPL-2.0-or-later, and three files in src/openvfsfuse/ with no SPDX tag
at all. libopenvfs is what downstream sync clients link against, so its
license determines the license of the combined binary they ship --
anyone doing that assessment got a different answer depending on which
file they opened first.

openvfs.cpp is aligned with the other four and with the top-level
LICENSE, the missing tags are added to main.cpp and sharedmap.{h,cpp}
(which already carried the full GPL-3 notice in prose), and the tag in
strtools.h moves above the include guard like everywhere else.

socketthread.{h,cpp} are deliberately left alone: they carry third-party
MIT code by David Lafreniere alongside the GPL-3 notice, and pinning a
single machine-readable expression on that combination is the
maintainers' call, not a drive-by fix. A REUSE.toml would make the whole
tree checkable with `reuse lint` and is worth doing as a follow-up.

Fixes #6
The bugs fixed in this branch are timing and framing dependent, which is
exactly the kind that comes back unnoticed. The test stands in for the
desktop client on a real AF_UNIX socket and drives the actual
SocketThread and SharedMap.

It covers a message split across two writes, a reply far larger than any
single read buffer, several replies batched into one write, the stream
still being in sync afterwards, a silent client timing out within its
deadline, a late reply being observed without a growing backoff, and
PostMsg reporting a message dropped during shutdown.

Verified to fail against the pre-fix framing code.

The repository had ctest wired up but no tests, so this also gives the
existing "Run tests" CI step something to run.
@dragotin

Copy link
Copy Markdown
Member

Thank you very much for this work. I took a first view and all looks very good to me.

Please give us some more time for review.
@TheOneRing FYI

@TheOneRing TheOneRing left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please split this pr.
There are many unrelated changes in this pr, and they should be discussed in separate pull requests.

Comment on lines +87 to 90
return "onlineonly";
case PinStates::AlwaysLocal:
return "dehydrated";
return "alwayslocal";
case PinStates::Inherited:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a breaking change, without many benefits.

@toxicphreAK

Copy link
Copy Markdown
Author

Split as requested. Rebased on current main, each one builds:

The test covers both the framing and the timeout, so it sits in #64 rather than in #61.

I dropped the name(PinStates) commit — @TheOneRing called it a breaking change without much benefit and I'd rather not hold up the rest over it. Happy to file it as an issue if you want it tracked.

One thing worth flagging: the Fixes #N lines in the old commits pointed at the wrong tracker. Fixes #3 here is "Discuss freedesktop spec". Removed them.

Closing this in favour of the four above.

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.

3 participants