Fix hidden DMs not reappearing live, and the recurring idle-transaction leak

Two related fixes:

1. A hidden DM's un-hide-on-new-message path only cleared
   RoomMembership.hidden_at in the DB -- it never told an already-open
   client to refresh. The only existing signal for that room
   (unread_update) does setRooms(prev => prev.map(...)), which is a
   no-op for a room that isn't in `prev` at all -- exactly what a
   hidden DM is. Now broadcasts the same room_added signal a brand new
   DM gets (via UPDATE ... RETURNING to know exactly who was
   un-hidden), reusing the fix already established for that class of
   bug.

2. While debugging #1's test, found the actual root cause behind the
   deploy-blocking migrations from earlier this session: every
   WebSocket connection shares one AsyncSession for its entire
   lifetime, and SQLAlchemy opens a transaction implicitly on first
   use. Nothing ever committed it -- not the initial auth lookup, not
   any of the several read-then-continue branches in the message loop
   (join/message/edit/reaction all check membership this way). A
   connection that's just sitting open (which for a real user can be
   hours) was holding that transaction open the entire time, which is
   exactly what blocked ALTER TABLE twice in production this session
   (confirmed both times via pg_stat_activity -- idle in transaction
   for 30+ minutes on this exact query shape). Now commits once after
   connection setup and once after every frame via a try/finally
   wrapping the whole dispatch, so no exit path (including the many
   `continue`s) can leave a transaction open while idling on the next
   receive_json().

Verified end-to-end in the browser (a hidden DM reappears in an
already-open tab with zero reload when the other person messages
again) and via a new WS-level test reproducing the exact scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:20:00 -06:00
co-authored by Claude Sonnet 5
parent 766883c992
commit 1d9fe25410
3 changed files with 212 additions and 144 deletions
+37
View File
@@ -168,6 +168,43 @@ def test_start_dm_notifies_other_participant_via_websocket(ws_client_factory):
assert received == {"type": "room_added", "room_id": room["id"]}
def test_new_message_notifies_recipient_who_hid_the_dm_via_websocket(ws_client_factory):
# A second production report on the same underlying gap: hiding a DM
# correctly clears out of GET /rooms/mine, but when the other person
# messages again, the *only* existing signal for that (unread_update)
# does `setRooms(prev => prev.map(...))` -- a no-op for a room that
# isn't in `prev` at all, which a hidden DM by definition isn't. Needs
# the same room_added signal a brand new DM gets, not just a DB-level
# un-hide.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
resp = instance2.post(f"/api/rooms/{room['id']}/hide")
assert resp.status_code == 204
with instance2.websocket_connect("/ws/chat") as bob_ws:
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "you there?"})
alice_ws.receive_json()
# Sync barrier (see test_mentions.py's identical helper): the
# message ack only proves the room-level broadcast happened,
# not that broadcast_new_message's own continuation (which
# un-hides the room and publishes room_added) has finished --
# a second frame's own ack proves that before this connection
# closes underneath it.
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
received = bob_ws.receive_json()
assert received == {"type": "room_added", "room_id": room["id"]}
def test_profile_update_notifies_room_members_via_websocket(ws_client_factory, monkeypatch):
# Only reaches clients that have the room's own channel joined --
# exactly the case where a stale avatar/display name would actually be