fix(store): don't close a shared filesystem in FsspecStore.close()#226
Open
d-v-b wants to merge 1 commit into
Open
fix(store): don't close a shared filesystem in FsspecStore.close()#226d-v-b wants to merge 1 commit into
d-v-b wants to merge 1 commit into
Conversation
d-v-b
force-pushed
the
fix/fsspec-shared-fs-close
branch
from
July 17, 2026 14:11
ce4f83d to
5d83b84
Compare
FsspecStore.close() closed the underlying filesystem's session, on the premise that a store built by from_url "owns" the filesystem it created. That premise does not hold: fsspec caches and shares filesystem instances across callers (its instance cache keys on storage options, not path), and users can hand one filesystem to many stores directly. Closing one store therefore killed the session that sibling stores were still using, and left the dead filesystem in fsspec's cache for later callers. Determining whether a filesystem is actually shared requires reaching into fsspec's private instance cache (_cache, _fs_token, cachable) and walking wrapper chains for caching/proxy filesystems — an implementation detail that leaks upward and that we would have to keep in sync with fsspec forever, getting it subtly wrong in between. The wrapper case alone (simplecache::/dir://) already slipped through a cache-membership check. The filesystem's lifecycle is simply not the store's to manage. This removes the ownership model added in the unreleased zarr-developersgh-4003: no _owns_fs, no _close_fs, no ownership transfer in with_read_only, and close() just marks the store not-open. The only thing given up is suppressing an "Unclosed client session" ResourceWarning, which was true anyway — the session belongs to a cached filesystem that outlives the store. Since zarr-developersgh-4003 never shipped (latest release is v3.2.1), its changelog fragment is removed rather than superseded. Assisted-by: ClaudeCode:claude-opus-4.8
d-v-b
force-pushed
the
fix/fsspec-shared-fs-close
branch
from
July 17, 2026 16:24
5d83b84 to
6503153
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 AI text below 🤖
Fixes F3 from the v3.3.0 evaluation — high-impact runtime breakage on the default
zarr.open("s3://...")path.The bug
from_urlsets_owns_fs = Trueunconditionally. The premise recorded in zarr-developers#4003 — "Trueonly whenFsspecStoreitself created the filesystem" — is false:url_to_fsgoes through fsspec's_Cachedmetaclass, which tokenizes on storage options only, not the path. Two stores built from two URLs on one host get the sameAbstractFileSystem, and each claims to own it.close()then kills the session for every sibling store, and because the dead filesystem stays incls._cache, stores created afterwards are handed the corpse too. The damage outlives the store that caused it.Two corrections to the finding as written
1. The MVCE in the writeup doesn't actually demonstrate the bug —
set_session()is a coroutine and it's never awaited, soassert session.closedwas passing on a coroutine object's missing attribute rather than a closed session. The underlying bug is real; I re-derived it with an awaited version.2.
skip_instance_cache=Truealone is not sufficient, though the writeup offers it as the fix._owns_fsis a plain attribute with no__getstate__anywhere, so it pickles asTruewhile the filesystem is rebuilt cache-served on the far side. On a dask worker the store owns a shared instance again and the bug is back. Detecting a "newly created" instance is also impossible —_fs_tokenis identical for a fresh and a cache-served instance, and a new cachable instance is inserted into_cachebefore__call__returns.The predicate that actually matters isn't "did I create it" but "can anyone else reach it" — which has to be asked at
close()time, not construction time.The fix (both halves)
from_urlpassesskip_instance_cache=True— the filesystem is genuinely private, so closing it is safe and fix(FsspecStore): close owned async filesystem on store.close() zarr-developers/zarr-python#4003's leak fix keeps working.close()re-checks_fs_is_shared(self.fs)against the live cache — self-correcting after a pickle roundtrip: the fs comes back cache-served, the check turnsclose()into a no-op, and no sibling is harmed.Each half covers where the other fails. The helper guards
cachable/_cache/_fs_tokenwithgetattrand degrades to "assume shared" — the safe answer — if fsspec's internals move.Also fixes
from_mapper, which the writeup doesn't mention. Itsfs is not original_fsheuristic assumes any new object is private, but_make_async's sync→async conversion round-trips throughto_json/from_json, which the cache serves. A sync s3fs mapper therefore wrongly claimed ownership. It now uses the same check.A caller who explicitly passes
skip_instance_cache=Falsestill gets a cached fs and correctly gets_owns_fs=False— the**optsordering respects user intent while staying safe.Tests
Four new tests, all failing on
mainand passing here:test_from_url_filesystem_is_private— distinct URLs, distinct fstest_close_does_not_close_a_sibling_stores_sessiontest_close_does_not_poison_the_instance_cache— a store created after the close still gets a live sessiontest_close_declines_to_close_a_shared_filesystem— the pickle-resurrection caseThe three session tests use HTTP rather than S3 deliberately: s3fs/aiobotocore transparently reconnects, so an S3-based assertion passes even with the bug present. I wrote the S3 versions first and they were vacuous — they passed against unfixed
main. HTTP's aiohttp session stays dead, which is where the user-visibleRuntimeError: Session is closedcomes from. No network I/O —set_session()only constructs the session.That reconnect behavior is also why this went unnoticed: the existing
test_from_url_close_releases_storeonly checksnot store._is_open, never that the session survived.Full suite: 6731 passed, 0 failed.
mypyclean.Known tradeoff
A
from_urlstore no longer compares equal to itself across a pickle roundtrip:skip_instance_cacheperturbs the fs token and isn't carried instorage_options, so unpickling reconstructs through the cache with a different token. Stores built from the same URL still compare equal, and no existing test covers the roundtrip case (test_serializable_store's fixture constructs the store directly with a suppliedfs). Flagging it explicitly sincetest_serializable_store's comment calls roundtrip equality "important for being able to compare (de)serialized stores" — happy to pursue a__getstate__that re-privatizes on unpickle if you'd rather not take this.Relatedly, unpickled stores on a worker share one cached fs and none will close it, so the session leaks there — safe, but zarr-developers#4003's original complaint in that narrow case.
Left alone
with_read_only()mutatingself._owns_fs = Falseon the source store is still there, per your call — a nominally derive-only method with a side effect that silently changes the source'sclose()behavior and makes it non-idempotent. Worth a separate look.