Skip to content
9 changes: 5 additions & 4 deletions src/mikeio/dfs/_dfs1.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ class Dfs1(_Dfs123):
def __init__(self, filename: str | Path) -> None:
super().__init__(filename)

self._dfs = DfsFileFactory.Dfs1FileOpen(str(filename))
self._x0: float = self._dfs.SpatialAxis.X0
self._dx: float = self._dfs.SpatialAxis.Dx
self._nx: int = self._dfs.SpatialAxis.XCount
dfs = DfsFileFactory.Dfs1FileOpen(str(filename))
self._x0: float = dfs.SpatialAxis.X0
self._dx: float = dfs.SpatialAxis.Dx
self._nx: int = dfs.SpatialAxis.XCount
dfs.Close()

origin = self._longitude, self._latitude
self._geometry = Grid1D(
Expand Down
25 changes: 12 additions & 13 deletions src/mikeio/dfs/_dfs3.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,21 +138,20 @@ def __init__(self, filename: str | Path):
)

def _read_dfs3_header(self, read_x0y0z0: bool = False) -> None:
self._dfs = DfsFileFactory.Dfs3FileOpen(self._filename)

self._source = self._dfs
dfs = DfsFileFactory.Dfs3FileOpen(self._filename)

if read_x0y0z0:
self._x0 = self._dfs.SpatialAxis.X0
self._y0 = self._dfs.SpatialAxis.Y0
self._z0 = self._dfs.SpatialAxis.Z0

self._dx = self._dfs.SpatialAxis.Dx
self._dy = self._dfs.SpatialAxis.Dy
self._dz = self._dfs.SpatialAxis.Dz
self._nx = self._dfs.SpatialAxis.XCount
self._ny = self._dfs.SpatialAxis.YCount
self._nz = self._dfs.SpatialAxis.ZCount
self._x0 = dfs.SpatialAxis.X0
self._y0 = dfs.SpatialAxis.Y0
self._z0 = dfs.SpatialAxis.Z0

self._dx = dfs.SpatialAxis.Dx
self._dy = dfs.SpatialAxis.Dy
self._dz = dfs.SpatialAxis.Dz
self._nx = dfs.SpatialAxis.XCount
self._ny = dfs.SpatialAxis.YCount
self._nz = dfs.SpatialAxis.ZCount
dfs.Close()

def read(
self,
Expand Down
1 change: 1 addition & 0 deletions src/mikeio/dfsu/_dfsu.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ def _dfs_read_item_time_func(
) -> tuple[np.ndarray, pd.Timestamp]:
dfs = DfsuFile.Open(self._filename)
itemdata = dfs.ReadItemTimeStep(item + 1, step)
dfs.Close()

return itemdata.Data, itemdata.Time

Expand Down
4 changes: 4 additions & 0 deletions src/mikeio/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,8 @@ def avg_time(
outdatalist[item][has_value] += d[has_value]
steps_list[item][has_value] += 1

dfs_i.Close()

for item in range(n_items):
darray = np.zeros_like(outdatalist[item], dtype=np.float32)
if skipna:
Expand Down Expand Up @@ -945,6 +947,8 @@ def quantile(
# TODO should this be static Z coordinates instead?
dfs_o.WriteItemTimeStepNext(0.0, znitemdata.Data)

dfs_i.Close()

for item in range(n_items_out):
darray = outdatalist[item].astype(np.float32)
dfs_o.WriteItemTimeStepNext(0.0, darray)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_dfs1.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from pathlib import Path
import gc
import os
import platform

import numpy as np
import pytest
import pandas as pd

import mikeio
from mikecore.DfsFileFactory import DfsFileFactory


def test_filenotexist() -> None:
Expand Down Expand Up @@ -171,3 +176,47 @@ def test_interp_onepoint_dfs1() -> None:

with pytest.raises(AssertionError, match="not possible for Grid1D with one point"):
ds[0].interp(x=0)


def _count_open_fds() -> int:
"""Count open file descriptors on Linux via /proc/self/fd."""
return len(os.listdir("/proc/self/fd"))


@pytest.mark.skipif(
platform.system() != "Linux",
reason="File descriptor counting via /proc only works on Linux",
)
def test_count_open_fds_sanity_check() -> None:
"""Verify _count_open_fds detects mikecore file handles."""
gc.collect()
baseline = _count_open_fds()

dfs = DfsFileFactory.DfsGenericOpen("tests/testdata/random.dfs1")
try:
assert _count_open_fds() > baseline, "opening a file must increase FD count"
finally:
dfs.Close()

gc.collect()
assert _count_open_fds() == baseline, "closing a file must restore FD count"


@pytest.mark.skipif(
platform.system() != "Linux",
reason="File descriptor counting via /proc only works on Linux",
)
def test_dfs1_init_closes_file_handle() -> None:
"""Dfs1.__init__ must not leak a file handle.

Before the fix, Dfs1.__init__ stored the open handle in self._dfs
without closing it, so each live instance held one file descriptor.
"""
gc.collect()
baseline = _count_open_fds()

instances = []
for _ in range(50):
instances.append(mikeio.Dfs1("tests/testdata/random.dfs1"))

assert _count_open_fds() - baseline == 0
49 changes: 49 additions & 0 deletions tests/test_dfs2.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from pathlib import Path
import datetime
import gc
import os
import platform
from typing import Any
from matplotlib import pyplot as plt
import numpy as np
Expand Down Expand Up @@ -918,3 +921,49 @@ def test_append_mismatch_geometry(tmp_path: Path) -> None:
dfs = mikeio.Dfs2(new_filename)
with pytest.raises(ValueError, match="geometry"):
dfs.append(ds2)


def _count_open_fds() -> int:
"""Count open file descriptors on Linux via /proc/self/fd."""
return len(os.listdir("/proc/self/fd"))


@pytest.mark.skipif(
platform.system() != "Linux",
reason="File descriptor counting via /proc only works on Linux",
)
def test_dfs2_init_closes_file_handle() -> None:
"""Dfs2.__init__ must not leak a file handle.

Dfs2.__init__ opens a Dfs2FileOpen handle to read spatial axis info
and must close it before returning.
"""
gc.collect()
baseline = _count_open_fds()

instances = []
for _ in range(50):
instances.append(mikeio.Dfs2("tests/testdata/eq.dfs2"))

assert _count_open_fds() - baseline == 0


@pytest.mark.skipif(
platform.system() != "Linux",
reason="File descriptor counting via /proc only works on Linux",
)
def test_dfs2_read_closes_file_handle() -> None:
"""Dfs2.read() must not leak file handles.

Each read() opens a Dfs2FileOpen handle via _open();
it must be closed before returning.
"""
gc.collect()
baseline = _count_open_fds()

results = []
for _ in range(50):
dfs = mikeio.Dfs2("tests/testdata/eq.dfs2")
results.append(dfs.read())

assert _count_open_fds() - baseline == 0
29 changes: 29 additions & 0 deletions tests/test_dfs3.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from pathlib import Path
import gc
import os
import platform

import pytest
import numpy as np

Expand Down Expand Up @@ -279,3 +283,28 @@ def test_append_dfs3(tmp_path: Path) -> None:
dfs = mikeio.Dfs3(new_fp)

dfs.append(ds2)


def _count_open_fds() -> int:
"""Count open file descriptors on Linux via /proc/self/fd."""
return len(os.listdir("/proc/self/fd"))


@pytest.mark.skipif(
platform.system() != "Linux",
reason="File descriptor counting via /proc only works on Linux",
)
def test_dfs3_init_closes_file_handle() -> None:
"""Dfs3._read_dfs3_header must not leak a file handle.

Before the fix, _read_dfs3_header stored the open handle in
self._dfs without closing it.
"""
gc.collect()
baseline = _count_open_fds()

instances = []
for _ in range(50):
instances.append(mikeio.Dfs3("tests/testdata/Grid1.dfs3"))

assert _count_open_fds() - baseline == 0
28 changes: 28 additions & 0 deletions tests/test_dfsu2dh.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from pathlib import Path
import gc
import os
import platform
import shutil

import numpy as np
Expand Down Expand Up @@ -1048,3 +1051,28 @@ def test_dfsu_to_xarray_has_element_coordinates() -> None:
assert xr_da.x.values[example_quad_element] == approx(example_quad_coordinates[0])
assert xr_da.y.values[example_quad_element] == approx(example_quad_coordinates[1])
assert xr_da.z.values[example_quad_element] == approx(example_quad_coordinates[2])


def _count_open_fds() -> int:
"""Count open file descriptors on Linux via /proc/self/fd."""
return len(os.listdir("/proc/self/fd"))


@pytest.mark.skipif(
platform.system() != "Linux",
reason="File descriptor counting via /proc only works on Linux",
)
def test_dfsu_read_closes_file_handle() -> None:
"""Dfsu read must not leak file handles.

Each read() opens a DfsuFile; it must be closed before returning.
"""
gc.collect()
baseline = _count_open_fds()

results = []
for _ in range(50):
dfs = mikeio.open("tests/testdata/HD2D.dfsu")
results.append(dfs.read())

assert _count_open_fds() - baseline == 0