Skip to content

Commit 634fcb8

Browse files
timon0305bradjin8cursoragent
authored
fix: wrap all production sqlite3.connect() in context managers (closes #17) (#23)
* fix: wrap all production sqlite3.connect() in context managers (closes #17) 19 production sqlite3.connect() call sites used a bare local variable with no `with` block, no try/finally, and no guaranteed .close() on exception. On a long-lived Flask process, leaks accumulate before the user notices. Two patterns applied: `with closing(sqlite3.connect(...)) as conn:` — used at 15 short-scope sites (composers ×3, cli_chat_reader ×2, cursor_md_exporter ×1, workspaces ×2 small helpers, logs ×2, search ×2, export_api ×1). sqlite3.Connection's own context manager only commits/rolls back, not closes. closing() is what guarantees .close(). `try / except / finally: if conn is not None: conn.close()` — used at 4 long-scope sites (workspaces.get_workspace_tabs ~400 lines, search.search ×2 deep blocks, export_api.export_chats ~340 lines, scripts/export.py). Same cleanup guarantee, no 100-300-line indent shift. Equivalent semantics to a body-wide `with closing(...)` block. Plus utils/workspaces._open_global_db converted to @contextmanager so its two callers write `with _open_global_db(...) as (conn, _):` and the connection is released automatically. Test-side sqlite3.connect calls are out of scope (per-test fixtures, short-lived). Verification: - All 19 production sites classified and confirmed protected (no bare `sqlite3.connect()` outside a closing()/finally guard). - pytest tests/ → 137 / 137 OK. - AST clean on all 8 modified files; contextlib imports correctly added wherever used. - Live HTTP smoke against the running app: home, /api/workspaces, /api/logs, /api/composers, /api/search all return 200 with no Python tracebacks in the log (exercises every wrapping pattern). * refactor: reuse _open_global_db in get_workspace_tabs; document unittest in README - Route GET /api/workspaces/<id>/tabs through the existing _open_global_db context manager instead of a duplicate sqlite3.connect + outer finally, matching list_workspaces and closing issue #17 review follow-ups. - README: add Tests section with python -m unittest discover tests -v. - test_exclusion_rules: docstring prefers unittest over pytest. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: f"file:{local_db_path}?mode=ro" builds an invalid SQLite URI when local_db_path contains characters that must be escaped in URIs --------- Co-authored-by: Monkey Dev <headit74@hotmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f8b3cb3 commit 634fcb8

10 files changed

Lines changed: 644 additions & 604 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@ python app.py
6969

7070
Open <http://localhost:3000> in your browser.
7171

72+
## Tests
73+
74+
Run the full suite from the repository root (install `requirements.txt` first):
75+
76+
```bash
77+
python -m unittest discover tests -v
78+
```
79+
80+
Run a single module, for example:
81+
82+
```bash
83+
python -m unittest tests.test_cli_args -v
84+
```
85+
7286
## CLI Export
7387

7488
Export chat history to Markdown without starting the web server. Running with no arguments exports **everything** (all chats + composer logs) as a zip archive into the current directory.

api/composers.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import os
99
import sqlite3
10+
from contextlib import closing
1011

1112
from flask import Blueprint, jsonify
1213

@@ -45,11 +46,11 @@ def list_composers():
4546
pass
4647

4748
try:
48-
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
49-
row = conn.execute(
50-
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
51-
).fetchone()
52-
conn.close()
49+
# closing() guarantees .close() on scope exit (issue #17).
50+
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
51+
row = conn.execute(
52+
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
53+
).fetchone()
5354

5455
if row and row[0]:
5556
data = json.loads(row[0])
@@ -86,11 +87,11 @@ def get_composer(composer_id):
8687
continue
8788

8889
try:
89-
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
90-
row = conn.execute(
91-
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
92-
).fetchone()
93-
conn.close()
90+
# closing() guarantees .close() on scope exit (issue #17).
91+
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
92+
row = conn.execute(
93+
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
94+
).fetchone()
9495

9596
if row and row[0]:
9697
data = json.loads(row[0])
@@ -104,12 +105,12 @@ def get_composer(composer_id):
104105
global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
105106
if os.path.isfile(global_db_path):
106107
try:
107-
conn = sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)
108-
row = conn.execute(
109-
"SELECT value FROM cursorDiskKV WHERE key = ?",
110-
(f"composerData:{composer_id}",),
111-
).fetchone()
112-
conn.close()
108+
# closing() guarantees .close() on scope exit (issue #17).
109+
with closing(sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)) as conn:
110+
row = conn.execute(
111+
"SELECT value FROM cursorDiskKV WHERE key = ?",
112+
(f"composerData:{composer_id}",),
113+
).fetchone()
113114

114115
if row and row[0]:
115116
raw = row[0] if isinstance(row[0], str) else row[0].decode("utf-8")

api/export_api.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import sqlite3
1212
import sys
1313
import zipfile
14+
from contextlib import closing
1415
from datetime import datetime
1516
from pathlib import Path
1617

@@ -78,6 +79,10 @@ def export_chats():
7879
application startup; an app restart is required to pick up changes to the
7980
exclusion rules file.
8081
"""
82+
# Outer try/finally guarantees the global-storage connection is closed
83+
# on every exit path including unexpected exceptions (issue #17). Keeps
84+
# the existing function body shape; just ensures cleanup.
85+
conn = None
8186
try:
8287
body = request.get_json(silent=True) or {}
8388
since = "last" if body.get("since") == "last" else "all"
@@ -131,17 +136,17 @@ def export_chats():
131136
if not os.path.isfile(db_path):
132137
continue
133138
try:
134-
wconn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
135-
row = wconn.execute(
136-
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
137-
).fetchone()
139+
# closing() guarantees .close() on scope exit (issue #17).
140+
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as wconn:
141+
row = wconn.execute(
142+
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
143+
).fetchone()
138144
if row and row[0]:
139145
data = json.loads(row[0])
140146
for c in (data.get("allComposers") or []):
141147
cid = c.get("composerId") if isinstance(c, dict) else None
142148
if cid:
143149
composer_id_to_ws[cid] = entry["name"]
144-
wconn.close()
145150
except Exception:
146151
pass
147152

@@ -402,8 +407,6 @@ def export_chats():
402407
except Exception as e:
403408
print(f"Error processing composer {composer_id} for export: {e}")
404409

405-
conn.close()
406-
407410
count = len(exported)
408411
if count == 0:
409412
return jsonify({"error": "No conversations to export" + (
@@ -436,3 +439,8 @@ def export_chats():
436439
import traceback
437440
traceback.print_exc()
438441
return jsonify({"error": f"Export failed: {str(e)}"}), 500
442+
finally:
443+
# Guaranteed close — fires on success, exception, AND on any
444+
# in-body return that doesn't go through except (issue #17).
445+
if conn is not None:
446+
conn.close()

api/logs.py

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import re
99
import sqlite3
10+
from contextlib import closing
1011
from datetime import datetime
1112

1213
from flask import Blueprint, jsonify
@@ -32,9 +33,10 @@ def get_logs():
3233
global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
3334
if os.path.isfile(global_db_path):
3435
try:
35-
conn = sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)
36-
conn.row_factory = sqlite3.Row
37-
rows = conn.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").fetchall()
36+
# closing() guarantees .close() on scope exit (issue #17).
37+
with closing(sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)) as conn:
38+
conn.row_factory = sqlite3.Row
39+
rows = conn.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").fetchall()
3840

3941
chat_map: dict[str, list] = {}
4042
for row in rows:
@@ -67,7 +69,6 @@ def get_logs():
6769
"type": "chat",
6870
"messageCount": len(bubbles),
6971
})
70-
conn.close()
7172
except Exception as e:
7273
print(f"Error reading global storage: {e}")
7374

@@ -91,43 +92,42 @@ def get_logs():
9192
pass
9293

9394
try:
94-
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
95-
96-
# Chat logs
97-
chat_row = conn.execute(
98-
"SELECT value FROM ItemTable WHERE [key] = 'workbench.panel.aichat.view.aichat.chatdata'"
99-
).fetchone()
100-
if chat_row and chat_row[0]:
101-
data = json.loads(chat_row[0])
102-
tabs = data.get("tabs") or []
103-
for tab in tabs:
104-
logs.append({
105-
"id": tab.get("id", ""),
106-
"workspaceId": name,
107-
"workspaceFolder": workspace_folder,
108-
"title": tab.get("title") or f"Chat {(tab.get('id') or '')[:8]}",
109-
"timestamp": tab.get("timestamp", 0),
110-
"type": "chat",
111-
"messageCount": len(tab.get("bubbles") or []),
112-
})
113-
114-
# Composer logs
115-
comp_row = conn.execute(
116-
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
117-
).fetchone()
118-
if comp_row and comp_row[0]:
119-
data = json.loads(comp_row[0])
120-
for c in (data.get("allComposers") or []):
121-
logs.append({
122-
"id": c.get("composerId", ""),
123-
"workspaceId": name,
124-
"workspaceFolder": workspace_folder,
125-
"title": c.get("text") or f"Composer {(c.get('composerId') or '')[:8]}",
126-
"timestamp": to_epoch_ms(c.get("lastUpdatedAt")) or to_epoch_ms(c.get("createdAt")) or 0,
127-
"type": "composer",
128-
"messageCount": len(c.get("conversation") or []),
129-
})
130-
conn.close()
95+
# closing() guarantees .close() on scope exit (issue #17).
96+
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
97+
# Chat logs
98+
chat_row = conn.execute(
99+
"SELECT value FROM ItemTable WHERE [key] = 'workbench.panel.aichat.view.aichat.chatdata'"
100+
).fetchone()
101+
if chat_row and chat_row[0]:
102+
data = json.loads(chat_row[0])
103+
tabs = data.get("tabs") or []
104+
for tab in tabs:
105+
logs.append({
106+
"id": tab.get("id", ""),
107+
"workspaceId": name,
108+
"workspaceFolder": workspace_folder,
109+
"title": tab.get("title") or f"Chat {(tab.get('id') or '')[:8]}",
110+
"timestamp": tab.get("timestamp", 0),
111+
"type": "chat",
112+
"messageCount": len(tab.get("bubbles") or []),
113+
})
114+
115+
# Composer logs
116+
comp_row = conn.execute(
117+
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
118+
).fetchone()
119+
if comp_row and comp_row[0]:
120+
data = json.loads(comp_row[0])
121+
for c in (data.get("allComposers") or []):
122+
logs.append({
123+
"id": c.get("composerId", ""),
124+
"workspaceId": name,
125+
"workspaceFolder": workspace_folder,
126+
"title": c.get("text") or f"Composer {(c.get('composerId') or '')[:8]}",
127+
"timestamp": to_epoch_ms(c.get("lastUpdatedAt")) or to_epoch_ms(c.get("createdAt")) or 0,
128+
"type": "composer",
129+
"messageCount": len(c.get("conversation") or []),
130+
})
131131
except Exception:
132132
pass
133133
except Exception:

api/search.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import re
99
import sqlite3
10+
from contextlib import closing
1011
from datetime import datetime
1112
from urllib.parse import unquote as _url_unquote
1213

@@ -83,6 +84,11 @@ def search():
8384
# Search global cursorDiskKV (new Cursor format — primary source)
8485
# ---------------------------------------------------------------
8586
if os.path.isfile(global_db_path):
87+
# try/finally guarantees .close() on every exit path including
88+
# exception (issue #17). Equivalent to wrapping the body in
89+
# `with closing(sqlite3.connect(...))`, without the 160-line
90+
# indent shift over the search logic that follows.
91+
conn = None
8692
try:
8793
conn = sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)
8894
conn.row_factory = sqlite3.Row
@@ -117,10 +123,11 @@ def search():
117123
if not os.path.isfile(db_path):
118124
continue
119125
try:
120-
wconn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
121-
row = wconn.execute(
122-
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
123-
).fetchone()
126+
# closing() guarantees .close() on scope exit (issue #17).
127+
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as wconn:
128+
row = wconn.execute(
129+
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
130+
).fetchone()
124131
if row and row[0]:
125132
data = json.loads(row[0])
126133
all_composers = data.get("allComposers")
@@ -129,7 +136,6 @@ def search():
129136
cid = c.get("composerId") if isinstance(c, dict) else None
130137
if cid:
131138
composer_id_to_ws[cid] = entry["name"]
132-
wconn.close()
133139
except Exception:
134140
pass
135141

@@ -244,9 +250,11 @@ def search():
244250
except Exception:
245251
pass
246252

247-
conn.close()
248253
except Exception as e:
249254
print(f"Error searching global storage: {e}")
255+
finally:
256+
if conn is not None:
257+
conn.close()
250258

251259
# ---------------------------------------------------------------
252260
# Search per-workspace ItemTable (legacy format — fallback)
@@ -270,6 +278,8 @@ def search():
270278
pass
271279
workspace_name = _workspace_display_name_from_folder(workspace_folder, fallback=name)
272280

281+
# try/finally guarantees .close() on every exit path (issue #17).
282+
conn = None
273283
try:
274284
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
275285

@@ -338,9 +348,11 @@ def search():
338348
"type": "chat",
339349
})
340350

341-
conn.close()
342351
except Exception:
343352
pass
353+
finally:
354+
if conn is not None:
355+
conn.close()
344356
except Exception:
345357
pass
346358

0 commit comments

Comments
 (0)