Fix Office session cleanup race

Keep newly-created Office sessions out of orphan cleanup so in-flight iframe loads do not lose their WOPI tokens during mount refreshes. Add regression coverage for the fresh-session grace window while preserving cleanup for older orphaned sessions.

Alessandro committed Apr 28, 2026 at 16:01 UTC 24812aabbb3e326ae173dcdd587027e583654ad2
2 files changed +29 -9
plugins/_office/helpers/wopi_store.py
+14 -9
@@ -25,6 +25,7 @@ PLUGIN_NAME = "_office"
25 SUPPORTED_EXTENSIONS = {"docx", "xlsx", "pptx", "odt", "ods", "odp"}
26 DEFAULT_TTL_SECONDS = 8 * 60 * 60
27 DEFAULT_LOCK_SECONDS = 30 * 60
28 +ORPHAN_SESSION_GRACE_SECONDS = 30
29 MAX_LOCK_SECONDS = 3600
30 MIN_LOCK_SECONDS = 60
31 MAX_SAVE_BYTES = 512 * 1024 * 1024
@@ -289,20 +290,24 @@ def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str
290 active_ids = {str(session_id).strip() for session_id in active_session_ids if str(session_id).strip()}
291 with connect() as conn:
292 _clear_expired_sessions(conn)
293 + cutoff = now() - ORPHAN_SESSION_GRACE_SECONDS
294 if active_ids:
295 placeholders = ",".join("?" for _ in active_ids)
296 rows = conn.execute(
295 - f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders})",
296 - tuple(active_ids),
297 + f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders}) AND created_at < ?",
298 + (*tuple(active_ids), cutoff),
299 ).fetchall()
298 - conn.execute(f"DELETE FROM tokens WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
299 - conn.execute(f"DELETE FROM sessions WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
300 - conn.execute(f"DELETE FROM locks WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
300 else:
302 - rows = conn.execute("SELECT session_id, file_id FROM sessions").fetchall()
303 - conn.execute("DELETE FROM tokens")
304 - conn.execute("DELETE FROM sessions")
305 - conn.execute("DELETE FROM locks")
301 + rows = conn.execute("SELECT session_id, file_id FROM sessions WHERE created_at < ?", (cutoff,)).fetchall()
302 +
303 + if not rows:
304 + return 0
305 +
306 + session_ids = tuple(row["session_id"] for row in rows)
307 + placeholders = ",".join("?" for _ in session_ids)
308 + conn.execute(f"DELETE FROM tokens WHERE session_id IN ({placeholders})", session_ids)
309 + conn.execute(f"DELETE FROM sessions WHERE session_id IN ({placeholders})", session_ids)
310 + conn.execute(f"DELETE FROM locks WHERE session_id IN ({placeholders})", session_ids)
311
312 for row in rows:
313 conn.execute(
tests/test_office_wopi_store.py
+15
@@ -113,6 +113,11 @@ def test_sync_open_sessions_closes_sessions_without_visible_tabs(office_state):
113 orphan = wopi_store.create_session(second["file_id"], "user-a", "write", "http://localhost:32080")
114 ok, _ = wopi_store.lock(second["file_id"], "orphan-lock", orphan["session_id"], 120)
115 assert ok is True
116 + with wopi_store.connect() as conn:
117 + conn.execute(
118 + "UPDATE sessions SET created_at = ? WHERE session_id = ?",
119 + (wopi_store.now() - wopi_store.ORPHAN_SESSION_GRACE_SECONDS - 1, orphan["session_id"]),
120 + )
121
122 assert wopi_store.sync_open_sessions([visible["session_id"]]) == 1
123
@@ -124,6 +129,16 @@ def test_sync_open_sessions_closes_sessions_without_visible_tabs(office_state):
129 wopi_store.validate_token(orphan["access_token"], second["file_id"])
130
131
132 +def test_sync_open_sessions_preserves_new_sessions_during_mount_race(office_state):
133 + doc = wopi_store.create_document("document", "Fresh", "docx", "new")
134 + session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
135 +
136 + assert wopi_store.sync_open_sessions([]) == 0
137 +
138 + token_info = wopi_store.validate_token(session["access_token"], doc["file_id"], require_write=True)
139 + assert token_info["session"]["session_id"] == session["session_id"]
140 +
141 +
142 def test_recent_documents_include_lightweight_previews(office_state):
143 doc = wopi_store.create_document("document", "Preview Memo", "docx", "A calm dashboard.")
144 sheet = wopi_store.create_document("spreadsheet", "Preview Sheet", "xlsx", "Name,Value\nOffice,1")