Skip to content

chore: Release - #55

Merged
efirs merged 4 commits into
releasefrom
main
Sep 29, 2026
Merged

efirs merged 4 commits into
releasefrom
main

Conversation

@efirs

@efirs efirs commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Note

Medium Risk
Changes default read-retry behavior and core lookup/cache invalidation paths; S3 copy and fsync synchronization fixes reduce failure modes but touch hot paths.

Overview
Fixes directory slurp/gap caching so listings are actually remembered: markGapLoaded no longer drops inserted ranges, non-truncated listings record an open-ended sentinel (gapEndOfListing) so lookups past the last key avoid repeat List/HEAD storms, overlapping ranges merge/split correctly, and dropLoadedRanges clears root gaps on cache refresh, unmount, and nested mount.

S3 backend: metadata self-copies that hit EntityTooLarge retry via multipart copy; owned multipart copies are aborted on failure; anonymous autodetection is skipped when UseIAM is enabled.

File I/O: shared numParts logic for multipart completion at tier boundaries; SyncFile holds the inode lock while deciding whether to wait on the flusher so completed flushes are not missed.

Defaults/UX: --read-retry-attempts defaults to 10 (minimum 1); FUSE readdirPlus skips extra refs for ./..; debug output labels open-ended gaps.

Extensive unit/e2e tests cover gaps, copy self-fallback, part counts, and sync.

Reviewed by Cursor Bugbot for commit 271f945. Bugbot is set up for automated code reviews on this repo. Configure here.

* fix: register for the flush wakeup before checking inode state

SyncFile checked flushPending and waited on flusherCond without holding the
inode lock, so a flush could complete between the state check and the wait and
the wakeup would be missed. The sync then blocks until some unrelated flush
happens to signal, or forever if none does.

Take the inode lock to read the state, acquire flusherMu before releasing it,
and only then wait, so completion cannot slip through the gap.

Cherry-picked from geesefs fbf052a, with its test. Our fork predates it. The
test fails against our current code with a one-second timeout and passes with
the fix.

(cherry picked from commit fbf052a89e2bc0ba21d7ca8db8b2d0e8b5f4d0a1)

* fix: bound the part count instead of indexing past the last part tier

completeMultipart derived the part count by taking partNum of the final size
and incrementing when the part started below it. At a size that exactly fills
the configured tiers that asks partRange for one part beyond the last, which
panics with "Part number too large".

Introduce numParts and maxParts, and use them, so the count saturates at the
maximum instead of running off the end.

Cherry-picked from geesefs 8fdec0d, with its tests; the module path in the test
is retargeted. Note this supersedes their earlier attempt at the same bug
(a5cc92e), which loosened the comparison in partRange and was reverted here for
allowing out-of-range parts. We have neither, so only this one should be taken.

Not reachable at ordinary file sizes: it needs a file that exactly fills every
part tier.

(cherry picked from commit 8fdec0de5a9c4b1e0f2b3d4c5a6b7c8d9e0f1a2b)

* fix: bound read retries instead of retrying forever by default

The read retry loop treats an attempt limit below one as unlimited, and the
flag defaulted to zero, so a persistently failing read retried without end and
the operation never returned an error to the caller.

Default the limit to ten and reject a value below one at startup, matching the
validation style already used for part sizes in the same function.

Cherry-picked from geesefs ae2c4ed.

Behaviour change worth calling out at review: unlimited retries were previously
reachable by passing zero, and that now aborts at startup rather than being
silently accepted. Anyone deliberately running unlimited has to change the flag.
The unlimited branch itself stays in the read loop, since the S3 backend still
lowers and restores the limit around its own calls.

(cherry picked from commit ae2c4edfcb1a2b3c4d5e6f708192a3b4c5d6e7f8)

* fix: do not take a lookup reference for . and .. in readdirplus

The readdirplus path referenced every entry it wrote, including the . and ..
entries. The kernel does not count those toward nlookup, so each listing leaked
a reference on the directory and its parent, and those inodes were never
forgotten.

Skip the reference for the two self entries.

Cherry-picked from geesefs bbedeb0. Our fork predates it.

(cherry picked from commit bbedeb027a1b2c3d4e5f60718293a4b5c6d7e8f9)

* fix: identify . and .. by directory position, not inode identity

The backported reference fix compares the entry against dh.inode and
dh.inode.Parent, but ReadDir releases the parent lock before returning, so a
rename can change dh.inode.Parent in between. The comparison then misses, the
.. entry is referenced, and no forget ever matches it: the leak the fix targets.

ReadDir yields . and .. at internal offsets 0 and 1, and dh.Next only advances
the position after this block, so the position still identifies this entry and
is held under dh.mu throughout. It is also correct for the root, where ReadDir
returns the directory itself for .. because there is no parent.

Divergence from geesefs bbedeb0 rather than a port; worth sending upstream,
which carries the same window.

Raised in review on #51.
Startup autodetection sends an unsigned HEAD at the bucket. A 200 means the
bucket answers unauthenticated requests, and the code took that, plus the
absence of a profile and of AWS_ACCESS_KEY_ID in the environment, as proof the
caller wanted anonymous access, and swapped the client onto anonymous
credentials.

With --iam neither of those is set, because credentials come from the metadata
service instead. A mount configured for IAM against a publicly readable bucket
therefore dropped to unauthenticated requests without saying so: every
subsequent call carried no identity, so writes fail and reads are attributed to
nobody.

Require that IAM is not in use before inferring anonymous access, and say in
the --no-detect help text that anonymous detection is one of the things it
turns off, which the old wording omitted.

Cherry-picked from geesefs a47ba58. Our fork predates it.

(cherry picked from commit a47ba5804b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e)
…owned uploads (#53)

Two defects on the server-side copy path.

A self-copy is used only to rewrite metadata, so the code assumed it is cheap
regardless of object size and always issued a single CopyObject. Some S3
implementations apply the single-copy size limit to a self-copy too and answer
EntityTooLarge, which failed the metadata update outright. Retry those as a
multipart copy, keeping the single request wherever the backend allows it.

Separately, the multipart copy helper created an upload and returned on error
without aborting it. The caller cannot clean up either, because the upload id
is never returned on failure, so every failed copy leaked an upload that counts
against storage until a lifecycle rule reaps it. The helper now aborts what it
created, and leaves caller-supplied uploads alone.

Cherry-picked from geesefs b3a2cec, with its tests; the module path in the test
is retargeted and our structured logging is kept over upstream's formatted call.
Reverting the fix fails three of the six sub-tests.

(cherry picked from commit b3a2cec67d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a)
#54)

* fix: make the slurp gap cache work, and remember when a listing ran off the end

Lookups on a directory whose stat cache has expired go to the server unless
the root's loaded-range list says the key was already covered by a listing.
That list has never held anything: markGapLoaded computed the final slice
length one short, so the range it inserted was truncated away every time.
With the list always empty, every such lookup issued a ListObjects and a HEAD.
This dates to the commit that introduced slurp-on-lookup in 2022 and is the
same upstream.

Once ranges are kept, a second defect surfaces for the reported workload. A
writer that keeps creating names sorting after every key on the server makes
each lookup list from that name; the listing returns nothing, and the range was
recorded up to the last key returned, or as empty. Nothing after it was ever
known, so the next name listed again. Record a listing that ran off the end
as open-ended instead, using a sentinel that sorts after any UTF-8 key.

markGapLoaded is rewritten to keep the list sorted and disjoint by trimming
overlapping ranges to their parts outside the new one, which also stops a
narrow re-list inside an open-ended range from discarding what is known past
its end.

Tests cover insertion, overlap trimming, splitting a containing range, the
end-of-listing sentinel, and expiry. On the previous code the first assertion
fails: marking (a,m] leaves the list empty.

* fix: actually emit loaded ranges in the inode debug dump

The dump built a map per range and never appended it, so the gaps field was
always empty. Append it, and render the end-of-listing sentinel readably.

* test: lookups past the last listed key must not list again

End-to-end version of the reported symptom: a directory with three files, an
expired stat cache, and nine lookups of names sorting after the last key. Every
lookup used to cost two ListObjects and a HEAD, because the listing that found
nothing past the name was never remembered as covering what follows.

Counted through a TestBackend wrapper against a live Tigris bucket. Before the
fix the listing count went 3 -> 21 over the nine lookups; after it, the later
lookups add no listings and no HEADs.

* fix: an explicit refresh must forget the loaded ranges too

With the loaded-range cache now actually holding entries, LookUp serves a name
from cache whenever a fresh range covers it. The .invalidate refresh reset the
subtree's DirTime and AttrTime but left those ranges alone, so the lookup it
provoked returned the very entries it had just expired: after a remote
overwrite and a refresh, a file still read its old contents. The four
notify-refresh tests caught it.

resetDirTimeRec now drops every range the root remembers before resetting
times. Over-invalidation is deliberate: a refresh is explicit and rare, and the
cost is one listing per lookup until ranges are re-learned. The recursion is
split into an inner function so the ranges are dropped once, not per node.

* fix: drop loaded ranges at the invalidation entry points, not inside the recursive reset

resetDirTimeRec took the root lock to drop the loaded ranges, but mount calls
it while holding the parent's lock. For a mount directly under the root that
is the same mutex, which is not reentrant, so the mount hung and left the root
locked against every later lookup. For a nested mount it took the root lock
while holding a child's, inverting the parent-before-child order and racing a
concurrent lookup for a deadlock.

Keep the recursive reset lock-free as it was. Drop the ranges at each entry
point instead, where no inode lock is held: RefreshInodeCache, ResetForUnmount,
and mount before it takes the parent's lock. dropLoadedRanges documents that it
takes only the root lock and must be called with none held.

Raised in review on #54.
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Fixes bugs in directory listing cache and file sync logic.

The PR should not merge until cache refresh cannot be undone by an in-flight listing.

Fix All in Claude CodeFindings

  1. P1 Refresh can restore stale ranges ▶

Summary

The PR changes S3 self-copy fallback and multipart cleanup, directory loaded-range caching and refresh, flush synchronization, retry defaults, and FUSE directory reference accounting. It also adds regression tests for these paths.

  • An in-flight listing can restore a loaded range after an explicit cache refresh clears it.

Reviews (1) · Last reviewed commit: "fix: stop re-listing on every lookup of ..."

Comment thread core/dir.go
Comment on lines +342 to +346
loadedEnd := nextStartAfter
if seal || !resp.IsTruncated {
loadedEnd = gapEndOfListing
}
parent.dir.markGapLoaded(NilStr(startWith), loadedEnd)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Refresh can restore stale ranges
If a directory listing is in flight during an explicit cache refresh, the refresh clears the root’s loaded ranges, but the older listing can finish afterward and record a fresh range. If an object was created after that listing’s snapshot, later lookups in the range return the cached absence instead of checking storage. The refresh therefore fails to reveal the object until the range expires.

Knowledge Base Used: Filesystem objects and operations

Fix in Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 271f945. Configure here.

Comment thread core/backend_s3.go
param.StorageClass = s.selectStorageClass(param.Size)
}

if !s.gcs && *param.Size > s.config.MultipartCopyThreshold {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fallback copy drops caller ETag

Medium Severity

The EntityTooLarge self-copy fallback always HEADs when StorageClass is unset and then replaces the caller-supplied ETag. Metadata flushes pass knownETag so the multipart copy can If-Match that version; after the overwrite, part copies pin whatever is live now. A remote resize or replace is applied with local metadata instead of returning ERANGE and discarding the stale update.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 271f945. Configure here.

@efirs
efirs merged commit cb3f4c7 into release Sep 29, 2026
13 of 14 checks passed
@tigrisdata-argocd-bot

Copy link
Copy Markdown

🎉 This PR is included in version 1.2.4 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants