The browser suite fails intermittently on a database connection nobody closed, and blames whichever test was running #294
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Postulo/postulo#294
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
uv run pytest -m e2efails perhaps one run in two, on a different test each time, andevery one of those tests passes when run on its own. Five full local runs during #247 and
#292 failed on
test_sticky_headertwice,test_submit_guardtwice and nothing once; CI'sbrowserjob has failed the same way at least once (run 514, job 3109) on a commit whoseother seven jobs were green.
It is not those tests. The failure is:
Why it moves around
Three things combine:
rather than closed.
filterwarnings = ["error"]inpyproject.toml:154promotes theResourceWarningthat finalising it raises into an exception.
attributes it to whichever test happens to be executing or setting up at that moment.
That is the whole explanation for the symptom that makes it look like a test bug: it
reproduces in a full run and never in isolation, because a short run does not give the
collector a reason to fire. It has been seen as a
FAILEDon a test body and as anERROR at setup ofa later one, which is the same event landing a few milliseconds apart.It is not new
tests/e2e/test_submit_guard.py:52-55already names the symptom, in a comment written on2026-09-16 (
9ebde4779) about a different cause:So one instance was found and fixed by waiting for the page. The general case -- a
connection outliving the thread that opened it, whatever the reason -- was not.
What it costs
A CI job that has to be looked at and usually dismissed, which is the expensive kind of
red: after the second dismissal nobody reads the third. It cost three investigations in
one session, and one of those was a real failure hiding behind the noise.
Worth deciding
live_serverruns Django in a thread; each threadgets its own connection and
close_old_connectionsis not called on the way out. Whetherit is the server thread, a request still in flight, or a fixture's own connection is the
first thing to establish -- the answer decides the fix.
browser test is the honest fix. Adding
ResourceWarningtofilterwarningswould makethe suite quiet and also make it blind: the same warning is how a genuinely leaked
connection would announce itself, and #226's real bug announced itself exactly this way.
may mean it cannot happen or may mean nothing has made the collector fire at the wrong
moment yet.
Classification
Bug, in the tests rather than in Postulo. Nothing a person using Postulo can see.
A third test, which is the point rather than a detail.
Two full browser runs back to back, same tree, nothing changed between them:
test_reflowpasses 4/4 in isolation, all three languages, and passed in run 2. Theobserved set is now
test_sticky_header,test_submit_guardandtest_reflow-- and thelast of those is the suite's longest test, which is what you would expect if the thing
being sampled is whatever happens to be executing when the collector fires.
Worth recording because of how it reads when it lands on a page test: the Greek reflow
failure looks exactly like a real 320-pixel overflow in a language nobody on the project
reads, which is a genuine bug this suite exists to catch. Ten minutes went into proving it
was not one. That is the cost of the noise, and it is larger than the red job itself.
A sixteen-second reproduction, and two hypotheses ruled out
Investigated without a fix landing. Recording it so the next attempt starts here rather
than at the beginning.
It can be reproduced in isolation after all
The issue says it "reproduces in a full run and never in isolation". That is true only
because nothing makes the collector fire. Add this to
tests/e2e/conftest.py:and
uv run pytest -m e2e --browser chromium tests/e2e/test_smoke.pyfails every time, inabout sixteen seconds, as
ERROR at teardown of test_the_critical_path. That is thewhole debugging loop, instead of seven minutes at roughly even odds.
It also does what the issue hoped for on a full run: the failure becomes an error at
teardown rather than a failure inside a random test body. It does not become
deterministic — three full runs blamed
test_the_critical_pathtwice andtest_the_header_is_there_after_scrolling_and_an_anchor_clears_itonce — so this is worthhaving as a debugging aid and is not worth committing on its own: it converts an
intermittent red into a constant one.
Where the connection comes from
Patching
sqlite3.connectandsqlite3.dbapi2.connect— Django's backend doesfrom sqlite3 import dbapi2 as Databaseand callsDatabase.connect, so patching only thefirst sees nothing — and recording a stack per connection gives, at the end of the smoke
test, two connections open:
with the second created through
ensure_connection→connect()inside a live-serverrequest thread. So it is a handler thread's own connection, and the test thread cannot
reach it:
connections.close_all()only ever sees the thread it runs on.Two things it is not
Not the wrapper Django hands the thread.
ThreadedWSGIServer.close_requestalreadycalls
connections.close_all()in the handler thread, and instrumenting it shows thethread holding exactly one connection at that moment — the shared one from
connections_override. Whatever leaks is gone from the registry before the request ends.Not the in-memory
close()guard. The obvious theory was that Django makesclose()ano-op when
is_in_memory_db(), so nothing ever shuts a connection a thread opened foritself. Giving the browser suite a file-backed database through
django_db_modify_db_settings— verified applied — changes nothing: same failure, sametest, same sixteen seconds.
Where to look next
The connection is created during a request and is not in the registry when that request
ends. The remaining candidate is a keep-alive handler thread:
daemon_threads = True,so a thread still parked in
handle()has neither returned nor reachedclose_request,and its connection is only finalised when the process tears it down. That would explain
both the thread name and the absence at
close_requesttime. Worth testing by closing thebrowser context, or by taking keep-alive out of the test server, before anything else.
Landed on
mainas30b132e42. Closing.The last lead in the investigation above was the right one, one step further on: the connection belonged to a live-server worker thread that had finished and never reached
close_request, so nothing in the thread's own registry was left to close it and the collector found it whenever it next ran, inside whichever test was executing. The fix does on time what the dead thread would have done: the browser suite sweeps the connections the live server's dead threads leave behind, so the collector is left nothing to find, and theResourceWarningstays switched on to announce a leak that is genuinely new.tests/e2e/test_db_leak_guard.pyplays the worker: a thread that shares the server's wrapper, reads the database, then hosts an event loop that reads it again, which is the shape of the PDF request. Without the sweep it fails.The browser job is green on the current head,
e143fc80d. Two runs on this machine on 2026-09-22, before the fix, were the last seen to blame a test at random (test_submit_guardandtest_sticky_header, neither with an assertion in the log). If it comes back, reopen this rather than filing it again: the sixteen-second reproduction above still applies.