Skip to content

Fix remote HTTP test stalls and tune HTTP HDF5 caching - #784

Open
d-chambers wants to merge 9 commits into
devfrom
fix-remote-hdf5-http-cache
Open

Fix remote HTTP test stalls and tune HTTP HDF5 caching#784
d-chambers wants to merge 9 commits into
devfrom
fix-remote-hdf5-http-cache

Conversation

@d-chambers

@d-chambers d-chambers commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Description

Tracks down the intermittent remote-HTTP deadlock/stall noted in the test_remote_http.py module docstring (the Windows skip rationale). Pinning the suite to 2 CPUs made it reproducible, which separated it into three independent causes.

1. Single-threaded fixture servers (test-only)

Two localhost fixture servers (http_regression_das_path, http_range_das_path) were single-threaded HTTPServers. One slow or abandoned connection parks the accept loop, and every new connection then sits in TCP SYN retry backoff — observed as reproducible 15 s stalls (the SYN retry schedule), 30 s pytest-timeout failures, or an indefinite hang when pytest-timeout isn't installed. Both fixtures now use ThreadingHTTPServer like the existing http_das_path fixture. Before: 2/3 pinned module runs hit the 30 s timeout; after: 5/5 stable.

2. A real deadlock between h5py's lock and garbage collection (product bug)

h5py holds its process-global lock while blocking on fsspec's event-loop thread for each remote fetch. Python's cyclic garbage collector can fire on any thread at any time — including that loop thread — and deallocating a dead h5py object there needs the same lock. So the loop thread waits on the lock while h5py waits on the loop thread, and neither moves.

This is platform-agnostic. Windows simply lost the timing coin flip more often, which is why it was previously filed as Windows flakiness. Remote h5py handles now pause automatic collection for the handle's lifetime (dascore.utils.remote_io.pause_gc) and resume it on close; reference counting still frees non-cyclic garbage.

The pause is balanced across the cases that can strand it: a BaseException raised during teardown, a handle inherited through a fork (handles record the pid that paused for them, so a child cannot resume a pause it never took), an interrupt inside a FiberIO read, and buffered wrappers that hide the fsspec filesystem from the loop-backed check. A rate-limited gc.collect() on the next remote open recovers a handle leaked inside a reference cycle, which __del__ cannot reach while collection is off.

Because this is fixed, the Windows skip is removed and windows-latest joins the network_tests matrix — Linux, macOS, and Windows now all exercise the localhost-HTTP path. That job is continue-on-error, so it reports without gating unrelated changes.

Note that the pause is process-global for the lifetime of an open remote handle: cyclic garbage from every thread accumulates until the last one closes. That is a deliberate trade against a hard deadlock, and it is documented on pause_gc.

3. HTTP HDF5 reads overfetched (performance)

Remote HDF5 opens over HTTP amplify transfers: h5py's metadata probe alternates between the file header and footer, and fsspec's default single-window BytesCache refetches a multi-MB block on every jump — debug logs showed 18+ MB fetched to probe a 12.7 MB file, and range-less servers re-stream the entire file per miss. H5Reader._get_open_kwargs already tuned S3 for exactly this reason; HTTP now gets the tuned block size too, with cache_type="blockcache" (a block LRU that keeps both file ends resident). Added a unit test mirroring the existing S3 one.

Separately, servers that report no size stream rather than range, so is_no_range_http_error now also routes "cannot seek streaming HTTP file" to the same local-file fallback.

Also here

dc.write to a remote path no longer commits a partial upload when the write fails — IOResourceManager.__exit__ aborts uncommitted work on error instead of closing it. close_all isolates per-handle failures so one bad close cannot skip the rest, since remote handles resume the GC pause in close.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Summary by CodeRabbit

  • New Features

    • Added support for HTTP and HTTPS remote HDF5 access.
    • Improved remote-read caching for HTTP-based resources.
  • Bug Fixes

    • Prevented deadlocks during asynchronous remote HDF5 reads.
    • Improved cleanup and garbage-collection handling for remote file handles.
    • Ensured resources close or abort correctly when operations fail.
    • Added fallback handling for streaming HTTP files that do not support seeking.
  • Tests

    • Expanded remote I/O coverage across Linux, macOS, and Windows.

The remote HTTP tests intermittently stalled for 15+ seconds (or hung
outright without pytest-timeout) under CPU contention. Two localhost
fixture servers were single-threaded, so one slow or abandoned
connection parked the accept loop and left new connections stuck in TCP
SYN retry backoff. Both fixtures now use ThreadingHTTPServer like the
existing http_das_path fixture.

Remote HDF5 opens over HTTP also amplified transfers: h5py's metadata
probe alternates between the file header and footer, and fsspec's
default single-window BytesCache refetched a multi-MB block (or the
entire file on range-less servers) on every jump - over 18 MB fetched to
probe a 12.7 MB file. HTTP now gets the same tuned block size as S3 with
a block LRU cache (blockcache) that keeps both file ends resident.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@d-chambers, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5447c5cf-3177-4360-b025-14d22da2b25f

📥 Commits

Reviewing files that changed from the base of the PR and between a55a4f5 and 14b6d57.

📒 Files selected for processing (11)
  • .github/workflows/runtests.yml
  • dascore/constants.py
  • dascore/io/core.py
  • dascore/utils/hdf5.py
  • dascore/utils/io.py
  • dascore/utils/remote_io.py
  • docs/changelog.qmd
  • tests/test_io/conftest.py
  • tests/test_io/test_io_core.py
  • tests/test_utils/test_gc_pause.py
  • tests/test_utils/test_io_utils.py
📝 Walkthrough

Walkthrough

Remote HDF5 opening now pauses garbage collection for loop-backed and remote handles, uses bounded HTTP block caching, improves failure cleanup and abort semantics, expands HTTP regression coverage, and enables network tests on Windows.

Changes

Remote HDF5 lifecycle hardening

Layer / File(s) Summary
GC pause coordination
dascore/utils/remote_io.py, dascore/utils/hdf5.py, tests/test_utils/test_gc_pause.py, tests/test_utils/test_io_utils.py
Remote reads use nested, fork-safe GC pause/resume state, with managed HDF5 handles restoring GC on close, leaks, and failures.
HDF5 open paths and HTTP caching
dascore/constants.py, dascore/utils/hdf5.py, tests/test_io/conftest.py, tests/test_io/test_remote_http.py, tests/test_utils/test_io_utils.py, .github/workflows/runtests.yml
HTTP and HTTPS use bounded block caching; loop-backed and remote UPath resources use fileobj-backed HDF5 handles, with expanded ranged-read coverage and Windows network testing.
Failure cleanup and abort semantics
dascore/io/core.py, dascore/utils/io.py, tests/test_io/test_io_core.py, tests/test_utils/test_io_utils.py
Failed type-casting closes newly created handles, while resource-manager contexts abort on exceptions and continue cleanup after individual close failures.

Possibly related PRs

Suggested labels: bug, IO, CI

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 67.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main theme of remote HTTP stalls and HTTP HDF5 caching, though it omits the GC deadlock fix.
Description check ✅ Passed The description follows the template with a Description section and Checklist, and it covers the main problem, fix, and tests.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-remote-hdf5-http-cache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added bug Something isn't working IO Work for reading/writing different formats labels Jul 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/test_utils/test_io_utils.py (1)

409-440: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover HTTPS in this regression test.

The implementation and constants now support both http and https, but this test exercises only http://. Parameterize the scheme so HTTPS regressions cannot pass unnoticed.

Proposed test update
-    def test_h5_reader_uses_block_cache_for_http_upath(self, monkeypatch):
+    `@pytest.mark.parametrize`("scheme", ("http", "https"))
+    def test_h5_reader_uses_block_cache_for_http_upath(self, monkeypatch, scheme):
...
-        path = UPath("http://example.com/example.h5")
+        path = UPath(f"{scheme}://example.com/example.h5")

As per PR objectives, HTTP and HTTPS HDF5 caching are both in scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_utils/test_io_utils.py` around lines 409 - 440, Parameterize
test_h5_reader_uses_block_cache_for_http_upath over both “http” and “https”
schemes, and construct the UPath URL from that parameter. Keep the existing
monkeypatches, configuration, and block-cache assertions unchanged so the
regression test covers both supported protocols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_utils/test_io_utils.py`:
- Around line 409-440: Parameterize
test_h5_reader_uses_block_cache_for_http_upath over both “http” and “https”
schemes, and construct the UPath URL from that parameter. Keep the existing
monkeypatches, configuration, and block-cache assertions unchanged so the
regression test covers both supported protocols.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8bd3f303-be17-44cd-a670-ca695eb36be5

📥 Commits

Reviewing files that changed from the base of the PR and between d4a21fc and 0ea86f3.

📒 Files selected for processing (4)
  • dascore/constants.py
  • dascore/utils/hdf5.py
  • tests/test_io/conftest.py
  • tests/test_utils/test_io_utils.py

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ea86f396c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread dascore/utils/hdf5.py Outdated
# alternates between the file header and footer, and fsspec's
# default single-window cache refetches a full block (or the whole
# file on range-less servers) on every jump.
cache_type = "blockcache" if protocol in ("http", "https") else "readahead"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cap the HTTP block cache

When an HTTP HDF5 read spans at least 32 blocks, fsspec's blockcache retains its default maximum of 32 blocks; with DASCore's default 5 MiB block size, each open remote file can therefore hold roughly 160 MiB of cached data until the handle closes. Large DAS files—or multiple concurrent readers—can cause a substantial memory regression compared with the previous single-window cache. Pass cache_options with a small maxblocks value sufficient to retain the header and footer rather than accepting fsspec's default.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87234% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.97%. Comparing base (076e049) to head (f4ca8be).

Files with missing lines Patch % Lines
dascore/io/core.py 87.50% 1 Missing ⚠️
dascore/utils/hdf5.py 97.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #784      +/-   ##
==========================================
- Coverage   99.98%   99.97%   -0.02%     
==========================================
  Files         164      164              
  Lines       17707    17783      +76     
==========================================
+ Hits        17705    17779      +74     
- Misses          2        4       +2     
Flag Coverage Δ
network 48.28% <70.21%> (+0.06%) ⬆️
unittests 99.97% <97.87%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Seeking a streaming (size-less) HTTP file raises a different fsspec
error than the no-range probe; match it in is_no_range_http_error so
such servers also fall back to a cached local copy. Cap the HTTP block
cache at 8 blocks so one handle cannot retain unbounded fetched data.
@d-chambers

Copy link
Copy Markdown
Contributor Author

Note on scope: this fixes the reproducible stall class (accept-loop parking — before: 2/3 pinned runs of the module hit the 30 s pytest-timeout; after: 5/5 stable) and the transfer amplification, but one intermittent stall remains in test_http_range_hdf5_read_succeeds: under CPU contention the ranged dc.read sometimes blocks for ~15 s (suspiciously the aiohttp keep-alive quantum) before completing, and the first CI run here hit it on the non-gating macOS network job (30 s timeout; the test's internal skip_on_timeout(15) can only fire after the blocked call returns). It's heisenbug-grade — enabling debug logging or the thread-method timeout makes it vanish — so I've left it rather than guess. If it keeps surfacing, the next lead is the aiohttp connection-reuse path for sequential range requests against connections the HTTP/1.0 server has just closed.

Root cause of the long-standing intermittent stall in remote HDF5 reads
(the TODO in test_http_range_hdf5_read_succeeds): h5py holds its global
phil lock while a remote fileobj read blocks on fsspec's event-loop
thread, and an automatic garbage collection triggered on that thread
must acquire phil to deallocate dead h5py objects. Main thread holds
phil waiting on the loop; the loop's GC waits on phil - deadlock.

Diagnosed with faulthandler burst dumps during a live stall (loop thread
frozen at an allocation point with the GC flag set, zero CPU) and
confirmed causally: gc.disable() eliminated the stall in 6/6 pinned
runs, and gc callbacks show collections running on the fsspecIO thread
exactly during range fetches.

Remote h5py handles now pause automatic collection for their (bounded)
lifetime, resuming on close - the mitigation h5py's file-object docs
recommend. Existing cyclic garbage is collected before each pause, the
pause is BaseException-safe, close() claims teardown atomically, and a
__del__ backstop covers leaked handles. The in-test skip_on_timeout
band-aid is removed so CI exercises the fixed path.
@d-chambers

Copy link
Copy Markdown
Contributor Author

Update: the residual stall is root-caused and fixed in ba6ba5c — it was never a network problem. It's a platform-agnostic ABBA deadlock: h5py holds its global phil lock while a remote fileobj read blocks on fsspec's event-loop thread, and an automatic GC cycle triggered on that loop thread needs phil to deallocate dead h5py objects (from the get_format call earlier in the same test). Main thread holds phil waiting on the loop; the loop's GC waits on phil.

Evidence: faulthandler burst dumps during a live stall show the loop thread frozen at an allocation point with Garbage-collecting set and ~0 CPU; gc.disable() eliminated the stall 6/6 (it reproduced by run 1–2 otherwise); gc.callbacks tracing shows collections running on the fsspecIO thread exactly during the 5 MB range fetches. This also explains the "Windows flakiness" the module docstring described, the heisenbug behavior (instrumentation shifts allocation counts), and why the in-test skip_on_timeout sometimes couldn't fire.

Fix: remote h5py handles pause automatic collection for their bounded lifetime (resumed on close) — the mitigation h5py's file-object docs recommend for exactly this pattern. Pre-pause gc.collect() bounds cycle buildup, the pause is BaseException-safe, close is race-safe, and a __del__ backstop covers leaks. The skip_on_timeout band-aid and stale TODO are removed so CI exercises the fixed path. Validated: 8/8 pinned repro runs clean (formerly deterministic stall), full suite green.

Three independent adversarial reviews of the GC-pause deadlock fix
surfaced real gaps, all addressed here:

- The io.IOBase branch of open_h5_resource bypassed the pause, so a
  user-supplied fsspec file object hit the original deadlock through a
  different door. Loop-backed file objects (fsspec async filesystems)
  now take the same paused path.
- _type_caster leaked the handle it opened when the wrapped FiberIO
  method raised, leaving collection paused indefinitely if the
  exception was retained. The except branch now closes created handles.
- IOResourceManager.__exit__ committed unconditionally, so a mid-write
  exception uploaded a partial file to remote targets; on error it now
  aborts handles that support it.
- pause_gc's maintenance collect moves outside the lock (a finalized
  leaked handle's close -> resume_gc would self-deadlock on the
  non-reentrant lock), runs unconditionally so leaked handles always
  self-heal on a later open, and is rate-limited to bound its cost when
  opening many remote files.
- Interrupt-safety: pause increments depth before disabling and resume
  re-enables before decrementing, so a KeyboardInterrupt between the
  two can no longer leave collection off permanently.
- A fork handler resets the pause state in child processes, matching
  the existing _reinit_after_fork pattern.
- Documented the load-bearing one-way reference invariant (nothing on
  the loop thread may reference h5py objects) in _FallbackFileObj.

The review also verified the fixture stack is Windows-correct and the
old skip's stated cause is fixed, so the win32 skip is removed and
windows-latest joins the (non-gating) network_tests matrix as a canary.
@d-chambers

Copy link
Copy Markdown
Contributor Author

Ran three independent adversarial reviews on the GC-pause mechanism (lock-ordering, CPython GC semantics, handle lifecycle/platforms) — commit 6a0a8ef addresses everything they surfaced:

Real gaps fixed:

  • io.IOBase branch bypassed the pause — a user passing an fsspec file object directly (dc.read(fsspec_file)) still hit the original deadlock. Loop-backed file objects now pause too.
  • _type_caster leaked its opened handle when a FiberIO method raised → GC could stay paused indefinitely. Exception path now closes.
  • IOResourceManager.__exit__ committed unconditionally → a mid-write exception uploaded a partial file to remote targets (pre-existing, adjacent). Now aborts on error.
  • Maintenance collect moved outside the pause lock (a finalized leaked handle would self-deadlock), made unconditional (leaked handles self-heal on later opens) and rate-limited (measured 7–160 ms per full collect — matters at spool scale).
  • KeyboardInterrupt-safe ordering in pause/resume; fork handler resets pause state in children (matching the existing _reinit_after_fork pattern).

Verified sound by the reviews (now documented in code): refcount-driven h5py deallocs can never land on the loop thread because references flow strictly one way through the sync bridge; the implementation matches h5py's file-object docs' recommended mitigation; wasm and free-threaded CPython carry over.

Windows: the review confirmed the fixture stack is Windows-correct and the skip's stated cause is the now-fixed platform-agnostic deadlock, so the win32 skip is removed and windows-latest joins the non-gating network_tests matrix as a canary.

The fork handler only runs in forked children and the loop-backed
fileobj constructor-failure path only on broken files, so neither was
exercised; call them directly. The codecov coords.py delta is a
line-skew artifact (one flagged line is a comment); the file is fully
covered locally on this branch.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.xdg-test/pint/01448202a267fcbe1a419a34e97f9f53346f5212.json:
- Line 1: Replace the machine-specific absolute source paths in the committed
Pint cache metadata with portable identifiers, or regenerate all entries in CI.
Apply this to .xdg-test/pint/01448202a267fcbe1a419a34e97f9f53346f5212.json:1-1,
.xdg-test/pint/a4391b0ab5f2b7b7b67c1b2c28cd86d2cb5b3dec.json:1-1,
.xdg-test/pint/c2a57dde47058a3a44a02d7d48817affe593f48e.json:1-1, and
.xdg-test/pint/db8943bf1adb3e18852fbc22864ab561c4a6be8c.json:1-1, preserving the
respective constants_en.txt and default_en.txt metadata.

In `@dascore/utils/io.py`:
- Around line 247-258: Update close_all to isolate cleanup failures per cached
handle: wrap each handle’s abort or close operation in exception handling so one
failure does not stop iteration through self._cache. Continue attempting every
remaining handle, while preserving the existing abort selection and normal close
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 20e16382-14c6-491b-8f48-3c212c63f518

📥 Commits

Reviewing files that changed from the base of the PR and between 0ea86f3 and 659973c.

⛔ Files ignored due to path filters (6)
  • .xdg-test/pint/01448202a267fcbe1a419a34e97f9f53346f5212.pickle is excluded by !**/*.pickle
  • .xdg-test/pint/0ffe33abec788d9f5d1f14291461e7bed27e2a4f.pickle is excluded by !**/*.pickle
  • .xdg-test/pint/a4391b0ab5f2b7b7b67c1b2c28cd86d2cb5b3dec.pickle is excluded by !**/*.pickle
  • .xdg-test/pint/c2a57dde47058a3a44a02d7d48817affe593f48e.pickle is excluded by !**/*.pickle
  • .xdg-test/pint/cfc24d8be1b723f7021ad520e66c8ae0e35e7152.pickle is excluded by !**/*.pickle
  • .xdg-test/pint/db8943bf1adb3e18852fbc22864ab561c4a6be8c.pickle is excluded by !**/*.pickle
📒 Files selected for processing (15)
  • .github/workflows/runtests.yml
  • .xdg-test/dascore/data/0.0.0/terra15_das_1_trimmed.hdf5
  • .xdg-test/pint/01448202a267fcbe1a419a34e97f9f53346f5212.json
  • .xdg-test/pint/0ffe33abec788d9f5d1f14291461e7bed27e2a4f.json
  • .xdg-test/pint/a4391b0ab5f2b7b7b67c1b2c28cd86d2cb5b3dec.json
  • .xdg-test/pint/c2a57dde47058a3a44a02d7d48817affe593f48e.json
  • .xdg-test/pint/cfc24d8be1b723f7021ad520e66c8ae0e35e7152.json
  • .xdg-test/pint/db8943bf1adb3e18852fbc22864ab561c4a6be8c.json
  • dascore/io/core.py
  • dascore/utils/hdf5.py
  • dascore/utils/io.py
  • dascore/utils/remote_io.py
  • tests/test_io/test_io_core.py
  • tests/test_io/test_remote_http.py
  • tests/test_utils/test_io_utils.py

Comment thread .xdg-test/pint/01448202a267fcbe1a419a34e97f9f53346f5212.json Outdated
Comment thread dascore/utils/io.py Outdated
One cached handle raising in IOResourceManager.close_all no longer
skips cleanup of the remaining handles (remote handles resume garbage
collection in close); the first error re-raises after all handles were
attempted, and __del__ suppresses it. Also remove the accidentally
committed .xdg-test cache directory. The block-cache memory concern
was already addressed by the maxblocks cap in an earlier commit.
Two adversarial reviews of the pause found six ways it could leave
automatic collection disabled for the rest of the process, or lift it
while a remote read was still running. All are fixed, and the machinery
around it is smaller than before.

Fixed:

- A BaseException from the owned file object's close skipped the resume
  entirely, with _closed already set so no retry and no __del__ could
  recover it. CancelledError out of fsspec, or Ctrl-C during a network
  close, was enough. The resume now sits in its own finally.
- A handle inherited through a fork resumed the child's own pause when
  closed there, re-enabling collection while the child was mid-read.
  Handles now record the pid that paused for them.
- _is_loop_backed_fileobj missed wrapped objects: io.BufferedReader over
  an fsspec HTTP file reads through the loop thread but has no .fs, so
  it took the unpaused branch with the deadlock window open. The check
  now unwraps a few levels, and duck-types async_impl instead of
  importing fsspec.asyn.
- _type_caster caught Exception, so Ctrl-C inside a FiberIO read leaked
  the handle it opened; the pause then lasted as long as the traceback,
  which under pytest or a REPL is the session.
- An interrupt between constructing the wrapper and clearing owns_pause
  could resume twice. Ownership is no longer expressed by a flag.
- The docstring claimed nothing could suppress collection indefinitely.
  A handle that is never closed does exactly that; it now says so.

Removed: the owns_pause dance duplicated at both call sites (one
_open_h5_paused helper), the per-handle close lock (an atomic dict.pop
gives resume-exactly-once), one of the two rate-limit globals, and the
sys.modules lookup.

The previous tests covered bookkeeping rather than the property: 93 of
97 passed with the pause removed entirely. The new ones model the real
cycle - a lock-holding reader waiting on a thread whose collection needs
that lock - and fail without it. Each of the seven breaks above was
mutated back in and confirmed caught.
@coderabbitai coderabbitai Bot added the CI continuous integration label Jul 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
dascore/utils/hdf5.py (1)

130-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use explicit is not None when picking the wrapped layer.

getattr(resource, "raw", None) or getattr(resource, "buffer", None) relies on truthiness. A file-like wrapper that defines __bool__/__len__ (falsy when empty) would be skipped, silently failing detection and reopening the deadlock window this function exists to close.

♻️ Proposed change
-        wrapped = getattr(resource, "raw", None) or getattr(resource, "buffer", None)
+        wrapped = getattr(resource, "raw", None)
+        if wrapped is None:
+            wrapped = getattr(resource, "buffer", None)
         if wrapped is None or wrapped is resource:
             return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/utils/hdf5.py` around lines 130 - 137, Update the wrapped-layer
selection in the resource traversal loop to prefer resource.raw whenever it is
not None, otherwise use resource.buffer, without relying on truthiness. Preserve
the existing None and self-reference termination checks and async_impl
detection.
dascore/utils/io.py (1)

264-265: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the intentional broad cleanup catch.

Ruff reports BLE001 here. The broad catch is justified because cleanup must continue for every handle, but add a narrowly scoped # noqa: BLE001 with rationale (or use a shared cleanup exception) so this does not become a CI lint failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/utils/io.py` around lines 264 - 265, Update the broad exception
handler in the cleanup loop around first_exc to add a narrowly scoped # noqa:
BLE001 with a concise rationale explaining that cleanup must continue across all
handles; preserve the existing first exception capture behavior and avoid
broader lint suppressions.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dascore/utils/hdf5.py`:
- Around line 151-155: Update the exception cleanup block surrounding the
visible fileobj.close() call so resume_gc() executes in a finally clause even
when close raises BaseException. Mirror the nested try/finally cleanup structure
used by _ManagedH5pyFile.close(), while preserving the existing re-raise
behavior.

---

Nitpick comments:
In `@dascore/utils/hdf5.py`:
- Around line 130-137: Update the wrapped-layer selection in the resource
traversal loop to prefer resource.raw whenever it is not None, otherwise use
resource.buffer, without relying on truthiness. Preserve the existing None and
self-reference termination checks and async_impl detection.

In `@dascore/utils/io.py`:
- Around line 264-265: Update the broad exception handler in the cleanup loop
around first_exc to add a narrowly scoped # noqa: BLE001 with a concise
rationale explaining that cleanup must continue across all handles; preserve the
existing first exception capture behavior and avoid broader lint suppressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e8d1ed25-c220-4aa9-9f7b-46880f9f6bd3

📥 Commits

Reviewing files that changed from the base of the PR and between 659973c and a55a4f5.

📒 Files selected for processing (6)
  • dascore/io/core.py
  • dascore/utils/hdf5.py
  • dascore/utils/io.py
  • dascore/utils/remote_io.py
  • tests/test_utils/test_gc_pause.py
  • tests/test_utils/test_io_utils.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • dascore/utils/remote_io.py
  • tests/test_utils/test_io_utils.py

Comment thread dascore/utils/hdf5.py
Comment on lines +151 to +155
except BaseException:
with suppress(Exception):
fileobj.close()
resume_gc()
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

resume_gc() can be skipped if fileobj.close() raises a BaseException.

suppress(Exception) doesn't cover KeyboardInterrupt/SystemExit, so an interrupt during teardown strands the pause with automatic collection off process-wide. _ManagedH5pyFile.close() already guards this with a nested try/finally; mirror it here.

🛡️ Proposed fix
     except BaseException:
-        with suppress(Exception):
-            fileobj.close()
-        resume_gc()
-        raise
+        try:
+            with suppress(Exception):
+                fileobj.close()
+        finally:
+            resume_gc()
+        raise
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except BaseException:
with suppress(Exception):
fileobj.close()
resume_gc()
raise
except BaseException:
try:
with suppress(Exception):
fileobj.close()
finally:
resume_gc()
raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/utils/hdf5.py` around lines 151 - 155, Update the exception cleanup
block surrounding the visible fileobj.close() call so resume_gc() executes in a
finally clause even when close raises BaseException. Mirror the nested
try/finally cleanup structure used by _ManagedH5pyFile.close(), while preserving
the existing re-raise behavior.

Both sides rewrote the remote IO layer, so the interesting part is
IOResourceManager.close_all: dev added the instance lock, this branch
added abort=True and per-handle error isolation (one handle failing must
not skip the others, since remote handles resume the GC pause in close).
The merge keeps all three.

Everything else auto-merged and was checked rather than assumed: both
fork handlers are present, dev's lru_cache/per-resource download locks
sit alongside this branch's GC pause, and __exit__ still aborts
uncommitted writes on error.
The branch grew through several review rounds; this pass is net -86 lines
while fixing two things the rounds left behind.

close_all caught Exception, so a KeyboardInterrupt from one handle's close
skipped every handle after it - the same leak _type_caster was widened to
BaseException to prevent, and remote handles resume the GC pause in close.
It now catches BaseException for that reason. clear_cache dropped its
cache only when close_all returned, so a failing handle left closed
handles cached; the clear moved to a finally.

pause_gc claimed its rate-limit slot outside the lock, so two openers
could each run the full collect. The claim moved inside; gc.collect()
stays outside, since finalizing a leaked handle calls resume_gc, which
takes that lock.

Removed, without losing coverage:

- The GC pause tests in test_io_utils duplicated test_gc_pause, which is
  the file that actually models the lock cycle (the older bookkeeping
  tests passed with the pause removed entirely).
- The S3 and HTTP open-kwargs tests differed only in the expected cache
  type, so they are one parametrized test.
- _DummyHandle was defined four times and the fsspec async filesystem
  fake twice; both are module level now. The fake still subclasses the
  real AsyncFileSystem, so the async_impl duck-type stays validated
  against fsspec rather than against another fake.
- The three localhost HTTP fixtures were identical apart from handler and
  root, and now share one _serve_das_tree context manager.

http/https was spelled three times (constants, remote_io, hdf5); it is
one constants.http_protocols that the tuned-protocol tuple builds on.

Changelog entries for the two user-visible behaviors: remote writes abort
rather than commit a partial upload on error, and remote HDF5 reads pause
automatic garbage collection while a handle is open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working CI continuous integration IO Work for reading/writing different formats

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant