[F135] re-check activity before sweeper closes a session (TOCTOU)

sweep_timed_out_sessions read last_activity_at once at the candidate
SELECT, then closed. A message landing in that window refreshed
last_activity_at in the DB, but the sweeper closed on its stale in-memory
value — closing a just-used session. Re-read last_activity_at fresh right
before the close and skip if the session is no longer timed out.
This commit is contained in:
Renn F
2026-06-29 00:35:14 +02:00
parent 4eb17ff7db
commit c2f42aec4a
2 changed files with 73 additions and 0 deletions
@@ -1020,6 +1020,38 @@ async def test_sweep_timed_out_sessions_closes_idle_session(
assert closed >= 1
@pytest.mark.asyncio
async def test_sweep_skips_session_refreshed_after_candidate_select(
msg_setup: dict, db_session: AsyncSession
) -> None:
"""TOCTOU: a session stale at the candidate SELECT but refreshed (a message
landed) before the close must NOT be closed. The sweeper re-reads
last_activity_at fresh and skips a just-used session."""
svc = msg_setup["svc"]
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
sess = await svc.create_session(
SessionCreateRequest(group_id=grp.id, timeout_seconds=1)
)
sid = sess.id
# Stale at SELECT time -> candidate.
sess.last_activity_at = datetime.now(UTC) - timedelta(seconds=120)
await db_session.flush()
# Fresh re-read sees stale -> still timed out.
assert (
await svc._session_still_timed_out(sid, timeout_seconds=1, max_time_window=None)
is True
)
# A message lands, refreshing activity.
sess.last_activity_at = datetime.now(UTC)
await db_session.flush()
# Fresh re-read sees recent -> no longer timed out -> sweeper must skip.
assert (
await svc._session_still_timed_out(sid, timeout_seconds=1, max_time_window=None)
is False
)
@pytest.mark.asyncio
async def test_edit_message_or_raise_succeeds(msg_setup: dict) -> None:
svc = msg_setup["svc"]