Skip to content

Commit 0be1c28

Browse files
committed
Merge remote-tracking branch 'origin/claude/datalad-fuse-readable' into claude/validate-stream-via-datalad-fuse
2 parents de6df64 + 7964633 commit 0be1c28

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

‎dandi/support/datalad_fuse.py‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@
3131
from dataclasses import dataclass, field
3232
from datetime import datetime
3333
from functools import cache
34+
import gc
3435
import logging
3536
import os
3637
from pathlib import Path
3738
import subprocess
39+
import threading
3840
from typing import IO, Any, cast
3941

4042
from ..misctypes import Readable
@@ -76,6 +78,62 @@ def annex_initialized(directory: Path) -> bool:
7678
return r.returncode == 0 and bool(r.stdout.strip())
7779

7880

81+
_gc_lock = threading.Lock()
82+
_gc_pauses = 0
83+
_gc_was_enabled = False
84+
85+
86+
class _GCPausedFile:
87+
"""
88+
A file object streamed by datalad-fuse, during whose lifetime automatic
89+
garbage collection is disabled
90+
91+
h5py holds a global lock while it reads from a Python file object, and the
92+
read waits for another thread (fsspec's I/O thread, or an HTTP server in
93+
the same process) to fetch the data. If a garbage collection ran in that
94+
thread meanwhile, finalizers of h5py objects (e.g., of an unreferenced
95+
``NWBHDF5IO``) would wait for the same lock, deadlocking the process.
96+
"""
97+
98+
def __init__(self, fp: Any) -> None:
99+
global _gc_pauses, _gc_was_enabled
100+
with _gc_lock:
101+
if _gc_pauses == 0:
102+
# Collect pending garbage now, in this thread, which does not
103+
# hold h5py's lock yet
104+
gc.collect()
105+
_gc_was_enabled = gc.isenabled()
106+
gc.disable()
107+
_gc_pauses += 1
108+
self._fp = fp
109+
self._paused = True
110+
111+
def __getattr__(self, name: str) -> Any:
112+
return getattr(self._fp, name)
113+
114+
def __enter__(self) -> _GCPausedFile:
115+
return self
116+
117+
def __exit__(self, *_exc: Any) -> None:
118+
self.close()
119+
120+
def __del__(self) -> None:
121+
if self._paused:
122+
self.close()
123+
124+
def close(self) -> None:
125+
global _gc_pauses
126+
try:
127+
self._fp.close()
128+
finally:
129+
with _gc_lock:
130+
if self._paused:
131+
self._paused = False
132+
_gc_pauses -= 1
133+
if _gc_pauses == 0 and _gc_was_enabled:
134+
gc.enable()
135+
136+
79137
@dataclass
80138
class AnnexedReadableFile(Readable):
81139
"""
@@ -95,7 +153,7 @@ class AnnexedReadableFile(Readable):
95153
adapter: Any = field(repr=False, compare=False)
96154

97155
def open(self) -> IO[bytes]:
98-
return cast("IO[bytes]", self.adapter.open(self.filepath, "rb"))
156+
return cast("IO[bytes]", _GCPausedFile(self.adapter.open(self.filepath, "rb")))
99157

100158
def get_size(self) -> int:
101159
return self.size

‎dandi/support/tests/test_datalad_fuse.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import gc
34
from pathlib import Path
45
import shutil
56

@@ -56,6 +57,26 @@ def test_annexed_readable(
5657
assert server.ranged_requests > 0
5758

5859

60+
@pytest.mark.ai_generated
61+
def test_annexed_readable_pauses_gc(
62+
streamed_nwb: tuple[Path, RangeHTTPServer],
63+
) -> None:
64+
# Automatic garbage collection is disabled while any streamed file is open
65+
# (to avoid deadlocks with h5py's lock; see `_GCPausedFile`)
66+
nwb, _ = streamed_nwb
67+
r = get_annexed_readable(nwb)
68+
assert r is not None
69+
assert gc.isenabled()
70+
with r.open() as fp:
71+
assert not gc.isenabled()
72+
with r.open() as fp2:
73+
assert fp2.read(8) == b"\x89HDF\r\n\x1a\n"
74+
assert not gc.isenabled()
75+
fp.seek(1)
76+
assert fp.read(3) == b"HDF"
77+
assert gc.isenabled()
78+
79+
5980
@pytest.mark.ai_generated
6081
def test_annexed_readable_none(
6182
streamed_nwb: tuple[Path, RangeHTTPServer], simple2_nwb: Path, tmp_path: Path

0 commit comments

Comments
 (0)