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")