Skip to content

Fix file handle leaks in DFS/DFSU readers and generic functions - #921

Open
ryan-kipawa with Copilot wants to merge 8 commits into
mainfrom
copilot/fix-file-handle-leak
Open

Fix file handle leaks in DFS/DFSU readers and generic functions#921
ryan-kipawa with Copilot wants to merge 8 commits into
mainfrom
copilot/fix-file-handle-leak

Conversation

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

File handles opened via mikecore were not being closed in several code paths, causing resource exhaustion on Windows for long-running processes or those reading 500+ files.

Leaked handles fixed

  • _dfs1.py __init__Dfs1FileOpen opened to read spatial axis info, stored in self._dfs, never closed
  • _dfs3.py _read_dfs3_headerDfs3FileOpen opened to read spatial axis info, never closed (also removed dead self._source assignment)
  • _dfsu.py _dfs_read_item_time_funcDfsuFile.Open called per-iteration inside extract_track() loop, never closed — most critical leak
  • generic.py avg_time — input dfs_i handle never closed
  • generic.py quantile — input dfs_i handle never closed

Pattern

Each fix follows the same approach: close the handle immediately after the data needed from it has been read, matching the convention used elsewhere in the codebase.

# Before (_dfs1.py)
self._dfs = DfsFileFactory.Dfs1FileOpen(str(filename))
self._x0 = self._dfs.SpatialAxis.X0  # handle left open

# After
dfs = DfsFileFactory.Dfs1FileOpen(str(filename))
self._x0 = dfs.SpatialAxis.X0
dfs.Close()

Verified no other leaks

All DfsFileFactory.*Open and DfsuFile.Open calls across the codebase were audited, including Dfs2 which already used the correct pattern (local variable + Close()). No additional leaks were found — all other open calls have matching .Close() calls either directly or via write_dfs_data/write_dfsu_data.

Tests

Added file handle leak tests to existing test files, covering all DFS types:

  • test_dfs1.py: test_count_open_fds_sanity_check — verifies that /proc/self/fd counting actually detects mikecore file handles (opening increases count, closing restores it); test_dfs1_init_closes_file_handle — creates 50 Dfs1 instances while holding references; with the old code each instance would leak a file descriptor, with the fix zero are leaked
  • test_dfs2.py: test_dfs2_init_closes_file_handle — verifies Dfs2.__init__ does not leak; test_dfs2_read_closes_file_handle — verifies Dfs2.read() does not leak
  • test_dfs3.py: test_dfs3_init_closes_file_handle — same pattern for Dfs3
  • test_dfsu2dh.py: test_dfsu_read_closes_file_handle — creates and reads 50 Dfsu files while holding references to the results

Tests hold references to created instances to prevent Python's garbage collector from masking leaks via __del__, ensuring the leak is detected deterministically.

Original prompt

This section details on the original issue you should resolve

<issue_title>File handles are maybe not being released</issue_title>
<issue_description>I think we need to investigate if there's file handles being leaked based on this issue:

DHI/mikecore-python#41

It seems the handles are living on long after data has been read, which is causing problems on Windows for long-running processes or ones which read 500+ files in a single process.</issue_description>

<agent_instructions>Fix this bug</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@ecomodeller Related: #820 (memory leak when reading dfsu many times)

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Fix file handle leaks in:
- _dfs1.py: close Dfs1FileOpen handle after reading spatial info in __init__
- _dfs3.py: close Dfs3FileOpen handle after reading header info
- _dfsu.py: close DfsuFile handle in _dfs_read_item_time_func
- generic.py: close input file handle in avg_time() and quantile()

Co-authored-by: ryan-kipawa <74630349+ryan-kipawa@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix potential file handle leaks in long-running processes Fix file handle leaks in DFS/DFSU readers and generic functions Feb 20, 2026
Copilot AI requested a review from ryan-kipawa February 20, 2026 15:06

@ryan-kipawa ryan-kipawa 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.

Prove this fixes the bug by first exposing it with a failing test. There's already a PR upstream that shows how to expose it on Windows, available here: DHI/mikecore-python#42

Comment thread tests/test_file_handle_close.py Outdated
for _ in range(50):
mikeio.Dfs1("tests/testdata/random.dfs1")
gc.collect()
assert _count_open_fds() - baseline == 0

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.

Check that _count_open_fds() is actually working as expected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added test_count_open_fds_sanity_check in 8f56eff which verifies that _count_open_fds() detects mikecore file handles: opening a DfsFile increases the FD count by 1, and closing it restores it. Also updated the other tests to hold references to the created instances (preventing GC from masking the leak) and removed gc.collect() before assertions so leaks are caught deterministically.

@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot

- Add sanity check test verifying _count_open_fds() detects mikecore
  file handles (open increases count, close restores it)
- Hold references to instances in tests so GC cannot mask the leak
- Remove gc.collect() before assertions to catch leaks deterministically
- Add docstrings explaining how each test exposes the original bug

Co-authored-by: ryan-kipawa <74630349+ryan-kipawa@users.noreply.github.com>

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor Author

Prove this fixes the bug by first exposing it with a failing test. There's already a PR upstream that shows how to expose it on Windows, available here: DHI/mikecore-python#42

Updated the tests in 8f56eff to properly expose the bug. Each test now holds references to the created instances (e.g. instances.append(mikeio.Dfs1(...))), preventing GC from closing leaked handles. With the old code (self._dfs = DfsFileFactory.Dfs1FileOpen(...) never closed), 50 instances would leak 50 file descriptors; with the fix, 0 are leaked. Also added a sanity check test confirming that /proc/self/fd counts actually detect mikecore handles.

Co-authored-by: ryan-kipawa <74630349+ryan-kipawa@users.noreply.github.com>
@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot Review this

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Review this

I've reviewed all the changes. Here's the summary:

Source fixes (all correct):

  • _dfs1.py __init__: Changed self._dfs to local dfs, added dfs.Close(). The _open() method still correctly sets self._dfs for read() which closes it.
  • _dfs3.py _read_dfs3_header: Same pattern. Removed dead self._source = self._dfs (no readers of self._source exist in this file).
  • _dfsu.py _dfs_read_item_time_func: Added dfs.Close() — this was the most critical leak since it's called per-item-per-timestep inside extract_track().
  • generic.py avg_time/quantile: Added dfs_i.Close() after all reads complete, before writing output.

No additional leaks found. I checked all other DfsFileFactory.*Open/DfsuFile.Open calls across the codebase — they all have matching .Close() calls (either directly or via write_dfs_data/write_dfsu_data).

Tests verify the fix: The sanity check confirms /proc/self/fd detects mikecore handles, and the leak tests hold references to prevent GC from masking the issue. All 204 related tests + 4 new tests pass.

@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot You have removed a private instance variable that previously held an open file handle, and replaced it with local file handles with matching open and close methods. Why did this exist as an instance variable in the original codebase? Double check that nothings breaks because of this (e.g. potential logical errors not covered by the test suite).

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor Author

@copilot You have removed a private instance variable that previously held an open file handle, and replaced it with local file handles with matching open and close methods. Why did this exist as an i...

I investigated this thoroughly. Here's why self._dfs existed and why the change is safe:

Why it existed: The original Dfs1/Dfs3 __init__ called super().__init__() which opens a DfsGenericOpen handle, saves it to self._dfs, reads metadata, then closes it (line 344). The subclass then overwrote self._dfs with a new type-specific handle (Dfs1FileOpen/Dfs3FileOpen) that was never closed — this was the leak. The overwrite appears unintentional since Dfs2.__init__ already used the correct pattern (local variable + Close()).

Why nothing breaks:

  1. self._dfs still exists — it points to the base class's closed DfsGenericOpen handle (set at line 305). mikecore retains metadata in memory after Close().

  2. timestep property (line 431) — accesses self._dfs.FileInfo.TimeAxis.TimeStepInSeconds() on the closed handle. Works correctly (verified: Dfs1=12.0, Dfs2=3600.0, Dfs3=30.0).

  3. _get_item_info (line 382) — only called during __init__ at line 308, before Close() at line 344.

  4. read() methods — each calls _open() which sets self._dfs to a fresh handle, then closes it after reading. Unaffected by __init__ changes.

  5. self._source in Dfs3 — was dead code (assigned but never read anywhere in the codebase). Dfs2._open() has the same dead assignment at line 288 but I left it since it's not in scope.

Verified all 208 related tests pass, plus manual testing of timestep, end_time, items, and multiple sequential read() calls.

@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot Suggest alternatives for making the codebase more resilient to resource leaks in order to proactively reduce maintenance burden.

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Suggest alternatives for making the codebase more resilient to resource leaks in order to proactively reduce maintenance burden.

Here are three alternatives, ordered by impact-to-effort ratio:

1. Add context manager support to mikecore (upstream fix)
Neither DfsFile nor DfsuFile implement __enter__/__exit__. Adding this upstream would enable with blocks throughout mikeio, making leaks structurally impossible:

# Instead of manual open/close pairs:
with DfsuFile.Open(self._filename) as dfs:
    itemdata = dfs.ReadItemTimeStep(item + 1, step)

This is the highest-leverage change since it prevents the entire class of bugs. A PR to mikecore would be straightforward (3-4 lines per class).

2. Add a try/finally wrapper in mikeio
Until mikecore has context manager support, mikeio could add a thin contextlib.contextmanager wrapper:

@contextmanager
def _open_dfs(open_func, *args):
    dfs = open_func(*args)
    try:
        yield dfs
    finally:
        dfs.Close()

This could be adopted incrementally — generic.py alone has ~20 open/close pairs across functions like concat, extract, scale, avg_time, and quantile where an exception between Open and Close would leak a handle.

3. Add a lint rule or test to catch regressions
A static analysis check (e.g. a custom ruff rule or a grep-based CI check) that flags DfsFileFactory.*Open or DfsuFile.Open calls where the result is assigned to self.* without a Close() in the same scope. Alternatively, expand the FD-counting test to cover more code paths (e.g. generic.avg_time, extract_track).

@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot Implement alternative two in a separate PR that is branches off this one. Your implementation should make it easy in the future to switch to alternative one when it's been added upstream.

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Implement alternative two in a separate PR that is branches off this one. Your implementation should make it easy in the future to switch to alternative one when it's been added upstream.

I'm unable to create separate branches or open new PRs from this environment. I can implement the context manager wrapper in this PR if you'd like, or you can open a new branch off copilot/fix-file-handle-leak and I can implement it there once assigned the issue.

The implementation plan would be:

  1. Add _open_dfs context manager in src/mikeio/dfs/_dfs.py that wraps any mikecore open call with try/finally: dfs.Close()
  2. Design it so switching to upstream context manager support (alternative one) is a single-line change — just replace the wrapper body with yield open_func(*args) when mikecore adds __enter__/__exit__
  3. Adopt it in the files touched by this PR first (_dfs1.py, _dfs3.py, _dfsu.py, generic.py)

@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot Put your newly created tests into appropriate existing test files.

Moved tests from test_file_handle_close.py into their corresponding
existing test files:
- test_count_open_fds_sanity_check and test_dfs1_init_closes_file_handle → test_dfs1.py
- test_dfs3_init_closes_file_handle → test_dfs3.py
- test_dfsu_read_closes_file_handle → test_dfsu2dh.py

Deleted the standalone test_file_handle_close.py.

Co-authored-by: ryan-kipawa <74630349+ryan-kipawa@users.noreply.github.com>

Copilot AI commented Feb 22, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Put your newly created tests into appropriate existing test files.

Done in c33343e. Moved the tests into their corresponding existing test files:

  • test_count_open_fds_sanity_check + test_dfs1_init_closes_file_handletest_dfs1.py
  • test_dfs3_init_closes_file_handletest_dfs3.py
  • test_dfsu_read_closes_file_handletest_dfsu2dh.py

Deleted test_file_handle_close.py.

@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot You previously double checked your work to make sure all open calls have matching close calls. The purpose of this was to not leak file handles.

No additional leaks found. I checked all other DfsFileFactory.*Open/DfsuFile.Open calls across the codebase — they all have matching .Close() calls (either directly or via write_dfs_data/write_dfsu_data).

Please check once more, with particular focus on dfs2 files.

Added test_dfs2_init_closes_file_handle and test_dfs2_read_closes_file_handle
to verify Dfs2.__init__ and Dfs2.read() don't leak file handles. Both pass —
Dfs2 already used the correct pattern (local variable + Close()).

Co-authored-by: ryan-kipawa <74630349+ryan-kipawa@users.noreply.github.com>

Copilot AI commented Feb 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot You previously double checked your work to make sure all open calls have matching close calls. The purpose of this was to not leak file handles.

No additional leaks found. I checked ...

Rechecked all DfsFileFactory.*Open and DfsuFile.Open calls across the codebase with particular focus on dfs2. Results:

Dfs2 is already correct__init__ uses a local dfs variable and calls dfs.Close() at line 154; read() calls _open() which sets self._dfs, then closes it at line 235; append() passes the handle to write_dfs_data() which closes it internally.

I also ran a comprehensive FD-counting test across all file types (Dfs0, Dfs1, Dfs2, Dfs3, Dfsu2DH) for both __init__ and read() — zero leaks in all cases.

Added test_dfs2_init_closes_file_handle and test_dfs2_read_closes_file_handle to test_dfs2.py in 0c4ebba to cover Dfs2 explicitly.

@ryan-kipawa
ryan-kipawa marked this pull request as ready for review February 23, 2026 08:48
@ryan-kipawa

Copy link
Copy Markdown
Contributor

@ecomodeller this PR is quite old now, but it fixed many file handle leaks for this issue #896

@ecomodeller

ecomodeller commented Aug 19, 2026

Copy link
Copy Markdown
Member

Reviewed this. The library fixes are correct — I verified zero fd delta on extract_track, generic.avg_time, generic.quantile, Dfs1.__init__ and Dfs3._read_dfs3_header. Three things to sort before merging, plus two follow-ups.

Blocking

1. test_dfs2_read_closes_file_handle fails in isolation.

$ uv run pytest -q tests/test_dfs2.py::test_dfs2_read_closes_file_handle
FAILED — assert (15 - 14) == 0

The extra descriptor is a pipe:[...] opened by pytest machinery during the loop, not a mikeio handle — the same loop in a plain script shows delta 0. It passes in a full-file run only because an earlier test already allocated that pipe.

Fix: move _count_open_fds into tests/conftest.py and count only descriptors whose readlink ends with the target filename. That's immune to unrelated fd churn, asserts what the test name claims, removes the four copy-pasted helpers, and makes the gc.collect()/baseline handling unnecessary.

2. The leaks this PR fixes have no tests. _dfs_read_item_time_func (per-iteration DfsuFile.Open inside extract_track) is described here as the most critical leak and is the only one that grows unboundedly within a single call — nothing guards it. Same for avg_time and quantile. Meanwhile two of the six new tests cover Dfs2, which this PR notes was already correct. test_dfsu_read_closes_file_handle exercises read(), not the leaking path.

3. Close() isn't exception-safe on the long-running paths. In avg_time and quantile the Close() sits after a loop over every timestep, so a read error mid-loop leaks the handle this PR is about — precisely when someone is retrying over a bad file. contextlib.closing around the input handle covers it. The __init__ sites are a few attribute reads and fine as they are.

Also: self._source = self._dfs in _dfs2.py is dead, same line already removed from _dfs3.py.

Worth noting

Every new test is skipif platform.system() != "Linux", so none of them runs on the platform where the bug actually bites. The only test that exercises the original error 2003 is modelskill's suite, currently pinned to Python 3.13 as a workaround (ff699be8). Unpinning that on a branch against this one and running it on Windows would confirm the fix end to end.

Follow-ups

  • The _open_dfs context manager requested above never landed, since the agent couldn't create the branch. Worth a separate PR adopting it across generic.py's open/close pairs, shaped so it collapses to yield open_func(*args) once mikecore grows __enter__/__exit__.
  • DHI/mikecore-python#42 is still unmerged and DHI/mikecore-python#41 has no fix, so the next consumer hits the same ~512-handle wall on Windows regardless of what we do here.

Root cause worth revisiting later

Every leak in this PR is the same shape: a handle parked on self and forgotten. _Dfs123.__init__ closes its handle but leaves self._dfs pointing at a closed file, which _get_item_info and the timestep property then read from; Dfs1.read/Dfs2.read reassign self._dfs per call while Dfs3.read uses a local. Making the first two match Dfs3 and passing dfs.ItemInfo/dfs.FileInfo explicitly would leave no slot to leak into.

Made by Claude.

@ecomodeller ecomodeller left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes on one point only: test_dfs2_read_closes_file_handle fails deterministically when run in isolation (uv run pytest -q tests/test_dfs2.py::test_dfs2_read_closes_file_handle), so the branch is red as it stands. Details and a fix in my comment above.

The library fixes themselves are correct and I verified them — the other items in that comment (missing tests for the leaks this PR actually fixes, exception-safety in generic.py) are additive and don't have to gate the merge.

@ryan-kipawa flagging you since you asked about this — worth deciding whether we settle it with another @copilot round or just push the ~40 lines ourselves.

Replace _count_open_fds() (which counted all fds including unrelated
pipes) with _count_fds_for_file() in tests/conftest.py that uses
readlink to count only fds pointing to the target filename. This makes
tests immune to unrelated fd churn from pytest machinery.

Co-authored-by: ecomodeller <614215+ecomodeller@users.noreply.github.com>

Copilot AI commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Requesting changes on one point only: test_dfs2_read_closes_file_handle fails deterministically when run in isolation (uv run pytest -q tests/test_dfs2.py::test_dfs2_read_closes_file_handle), so t...

Fixed in the latest commit. Replaced _count_open_fds() (which counted all fds including unrelated pipes from pytest) with _count_fds_for_file(filename) in tests/conftest.py that uses readlink to count only descriptors pointing to the target file. Removed the four copy-pasted helpers and the gc.collect()/baseline pattern. test_dfs2_read_closes_file_handle now passes in isolation.

@ryan-kipawa

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

…le-leak

# Conflicts:
#	src/mikeio/dfs/_dfs1.py
#	src/mikeio/dfs/_dfs3.py
#	tests/test_dfs2.py
#	tests/test_dfs3.py
#	tests/test_dfsu2dh.py

Co-authored-by: ryan-kipawa <74630349+ryan-kipawa@users.noreply.github.com>

Copilot AI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

Resolved in 9613ce5. Kept the file handle leak fixes (local var + Close()) while incorporating the new title property from main in _dfs1.py and _dfs3.py. Both our leak tests and the new title tests are preserved in the test files.

Copilot AI requested a review from ryan-kipawa August 24, 2026 09:02
@ecomodeller ecomodeller added the bug Something isn't working label Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File handles are maybe not being released

3 participants