diff --git a/db/connection.py b/db/connection.py index 5eff1c3a..21805dae 100644 --- a/db/connection.py +++ b/db/connection.py @@ -17,4 +17,56 @@ def connect(slug): conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA busy_timeout = 8000") + _track_for_teardown(conn) return conn + + +def _track_for_teardown(conn): + """Filet de sécurité : chaque fonction de db/ ouvre sa propre connexion + et est censée la fermer elle-même (conn.close()) avant de rendre la + main — mais si une exception survient ENTRE l'ouverture et cette + fermeture (une erreur de programmation, une contrainte violée...), le + conn.close() prévu n'est jamais atteint. En mode debug (voir app.py), + le débogueur Werkzeug garde alors la trace complète de l'erreur en + mémoire pour l'inspection interactive — ce qui inclut la variable + locale `conn`, empêchant le ramasse-miettes Python de la libérer et + donc SQLite de relâcher son verrou d'écriture. Toute requête suivante + qui écrit se heurte alors à "database is locked" jusqu'au redémarrage + du serveur, même longtemps après l'erreur d'origine. En enregistrant + ici la connexion sur le contexte de la requête Flask en cours (quand il + y en a un), on garantit sa fermeture à la fin de la requête via + _close_leaked_connections ci-dessous, que la requête ait réussi ou + planté — sans rien changer au comportement des ~80 fonctions qui + ferment déjà correctement leur connexion (fermer une connexion SQLite + déjà fermée ne fait rien).""" + try: + from flask import g, has_app_context + except ImportError: + return + if not has_app_context(): + return + if not hasattr(g, "_forge_db_connections"): + g._forge_db_connections = [] + g._forge_db_connections.append(conn) + + +def _install_teardown_safety_net(): + """Appelé une seule fois (voir le bas de ce fichier) — enregistre le + filet de sécurité sur l'appli Flask. `core.flask_app` ne dépend de rien + dans `db/`, donc cet import ne crée pas de dépendance circulaire.""" + try: + from core.flask_app import app + except ImportError: + return + + @app.teardown_request + def _close_leaked_connections(exception=None): # noqa: ARG001 - signature imposée par Flask + from flask import g + for conn in getattr(g, "_forge_db_connections", ()): + try: + conn.close() + except sqlite3.Error: + pass + + +_install_teardown_safety_net() diff --git a/tests/test_db_connection_leak_safety_net.py b/tests/test_db_connection_leak_safety_net.py new file mode 100644 index 00000000..6cfc6065 --- /dev/null +++ b/tests/test_db_connection_leak_safety_net.py @@ -0,0 +1,43 @@ +"""Régression : une connexion SQLite ouverte puis jamais fermée à cause +d'une exception ne doit plus bloquer les écritures suivantes avec +"database is locked" — voir _track_for_teardown()/_install_teardown_ +safety_net() dans db/connection.py, qui ferme toute connexion encore +ouverte à la fin de la requête, que celle-ci ait réussi ou planté.""" +import db +import screens + +from core.flask_app import app + + +def test_connection_left_open_by_a_crashed_request_is_closed_at_teardown(game): + """Simule exactement l'incident remonté : du code qui ouvre une + connexion en écriture puis plante avant de la fermer (comme + delete_element.py le ferait si conn.execute() levait une exception), + à l'intérieur d'une requête Flask (test_request_context — quitter le + "with" déclenche le même teardown_request qu'une vraie requête, qu'elle + ait réussi ou non).""" + screens.list_screens(game) # garantit que _screens existe déjà (ensure_schema) + try: + with app.test_request_context(f"/__test_leak/{game}"): + conn = db.connect(game) + conn.execute("INSERT INTO _screens (name, is_template) VALUES ('x', 0)") + raise RuntimeError("simulate a crash before conn.commit()/conn.close()") + except RuntimeError: + pass + + # Si la connexion précédente n'avait pas été fermée par le filet de + # sécurité, cette écriture échouerait avec sqlite3.OperationalError: + # database is locked (timeout=10s côté connect(), donc le test + # planterait/traînerait plutôt que de simplement échouer vite). + screen_id = screens.create_screen(game, "Un écran normal") + assert screen_id + + +def test_connection_is_not_tracked_outside_a_request_context(game): + """Non-régression : un appel direct à db.connect() en dehors de tout + contexte Flask (scripts, tests) ne doit pas planter — has_app_context() + doit simplement renvoyer False et ne rien suivre.""" + with app.app_context(): + pass # contexte créé puis immédiatement fermé, comme hors requête + conn = db.connect(game) + conn.close()