Dev #1
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user