From b2327f6caa515f69a5cc79954e5df0e6f6ab7ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:33:54 +0000 Subject: [PATCH] execute(): close the cursor in a finally block A traceback that keeps the frame alive (e.g. QueryInterrupted from a facet suggestion) kept the statement unfinalized; when the pool later closed that connection it became a zombie that held its fds until the next gc. With this, fd cross-check matches in 3/3 N=1000 runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EwkLo2rJWidpkDVT3HkvNv --- datasette/database.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index 68ab37e0..8948d02c 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -743,6 +743,7 @@ class Database: ) as execute_span: try: with sqlite_timelimit(conn, time_limit_ms): + cursor = None try: cursor = conn.cursor() cursor.execute(sql, params if params is not None else {}) @@ -756,6 +757,7 @@ class Database: else: rows = cursor.fetchall() truncated = False + description = cursor.description except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: if e.args == ("interrupted",): raise QueryInterrupted(e, sql, params) @@ -765,6 +767,13 @@ class Database: ) sys.stderr.flush() raise + finally: + # Release the statement now: a traceback that + # keeps this frame alive would otherwise keep it + # unfinalized, and closing the pooled connection + # later would leave a zombie holding its fds + if cursor is not None: + cursor.close() except QueryInterrupted as e: if not timeout_expected: execute_span.record_exception(e) @@ -777,10 +786,10 @@ class Database: raise if truncate: - return Results(rows, truncated, cursor.description) + return Results(rows, truncated, description) else: - return Results(rows, False, cursor.description) + return Results(rows, False, description) with trace( # noqa: SIM117 "sql", database=self.name, sql=sql.strip(), params=params