Dev #1

Merged
w-vandal merged 62 commits from dev into main 2026-08-26 04:56:44 +00:00
2 changed files with 95 additions and 0 deletions
Showing only changes of commit 79fe04c6ac - Show all commits
+52
View File
@@ -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()