From c0153386ef20126a289da96204718570d571b4b2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 16 Apr 2026 20:18:05 -0700 Subject: [PATCH] FD-leak regression test for Datasette.close() Creates and disposes 50 Datasette instances in a loop and asserts that the number of open file descriptors and live threads does not grow, exercising the full close() path end to end. Refs #2692 Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 1 + tests/test_fd_leak.py | 56 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/test_fd_leak.py diff --git a/pyproject.toml b/pyproject.toml index 4a4ed75e..e6007afd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dev = [ "myst-parser", "sphinx-markdown-builder", "ruamel.yaml", + "psutil>=5.9", ] [project.optional-dependencies] diff --git a/tests/test_fd_leak.py b/tests/test_fd_leak.py new file mode 100644 index 00000000..926722a1 --- /dev/null +++ b/tests/test_fd_leak.py @@ -0,0 +1,56 @@ +""" +Regression test for https://github.com/simonw/datasette/issues/2692 — +confirm that creating and closing Datasette instances in a loop does not +leak open file descriptors. + +Each Datasette() with is_temp_disk internal DB opens a temp file and a +write thread with its own SQLite connection. Without Datasette.close() +nothing unwinds this state, and a large pytest run exhausts the process +FD limit. +""" + +import asyncio +import threading + +import pytest + +try: + import psutil +except ImportError: # pragma: no cover + psutil = None + +from datasette.app import Datasette + + +def _count_open_files(): + return len(psutil.Process().open_files()) + + +def _count_threads(): + return threading.active_count() + + +@pytest.mark.skipif(psutil is None, reason="psutil not installed") +def test_close_releases_file_descriptors(): + # Warm-up so Python/library caches don't skew the baseline + ds = Datasette(memory=True) + asyncio.run(ds.invoke_startup()) + ds.close() + + baseline_fds = _count_open_files() + baseline_threads = _count_threads() + + for _ in range(50): + ds = Datasette(memory=True) + asyncio.run(ds.invoke_startup()) + ds.close() + + after_fds = _count_open_files() + after_threads = _count_threads() + + assert ( + after_fds - baseline_fds <= 2 + ), f"Leaked FDs: baseline={baseline_fds}, after=50 iterations={after_fds}" + assert ( + after_threads - baseline_threads <= 2 + ), f"Leaked threads: baseline={baseline_threads}, after={after_threads}"