Skip to content

Commit 9fc2907

Browse files
committed
Fix pycrdt cross-thread GC crash in collab worker and server
pycrdt's Rust objects are !Send; the automatic, threshold-triggered GC could collect a Doc/Subscription on a different thread than the one that created it, crashing with a pyo3 unsendable-object error. - gunicorn (business/collab.py): route all pycrdt calls through a single dedicated worker thread (iris_engine/collab/pycrdt_worker.py) and disable automatic GC process-wide, collecting explicitly only on that thread. - collab_server.py (uvicorn, single event loop thread): add a periodic gc.collect() on the event-loop thread itself, since all pycrdt objects there are already created/destroyed on that one thread.
1 parent 72e6263 commit 9fc2907

3 files changed

Lines changed: 98 additions & 3 deletions

File tree

source/app/business/collab.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from app.db import db
4040
from app.iris_engine.collab.render import markdown_to_ydoc_update
4141
from app.iris_engine.collab.render import ydoc_update_to_markdown
42+
from app.iris_engine.collab.pycrdt_worker import run as _run_on_pycrdt_thread
4243
from app.iris_engine.utils.tracker import track_activity
4344
from app.models.authorization import CaseAccessLevel
4445
from app.models.authorization import WarRoomAccessLevel
@@ -259,7 +260,7 @@ def ensure_snapshot(doc_name, current_content):
259260
# it would leave users unable to see content they know is in
260261
# `note.note_content` / `case.description` / etc.
261262
seed_md = current_content or ''
262-
seed_bytes = markdown_to_ydoc_update(seed_md)
263+
seed_bytes = _run_on_pycrdt_thread(markdown_to_ydoc_update, seed_md)
263264

264265
if row is None:
265266
row = CollabDoc(
@@ -312,7 +313,12 @@ def apply_wire_update(doc_name, update_b64, user_id):
312313
# combining update blobs without needing to materialise a full
313314
# Doc — much cheaper than apply+get_update on hot paths. It's
314315
# a variadic (not a list), hence the star-splat.
315-
new_state = merge_updates(bytes(row.y_state), update_bytes)
316+
#
317+
# Routed through the dedicated pycrdt worker thread: see
318+
# `iris_engine.collab.pycrdt_worker` for why — the transient
319+
# Doc/Subscription objects merge_updates allocates internally
320+
# are `!Send` and must not be GC'd off-thread.
321+
new_state = _run_on_pycrdt_thread(merge_updates, bytes(row.y_state), update_bytes)
316322
except Exception:
317323
# Malformed update — drop it. Peers won't see the broadcast
318324
# either (caller checks the return value).
@@ -348,7 +354,7 @@ def flush_to_source(doc_name):
348354
return
349355

350356
try:
351-
new_content = ydoc_update_to_markdown(bytes(row.y_state))
357+
new_content = _run_on_pycrdt_thread(ydoc_update_to_markdown, bytes(row.y_state))
352358
except Exception:
353359
# Never let a bad snapshot break the audit path — keep the
354360
# source column as-is until a subsequent flush succeeds.

source/app/collab_server.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from __future__ import annotations
1111

12+
import asyncio
13+
import gc
1214
import json
1315
import os
1416
import re
@@ -46,6 +48,15 @@
4648
)
4749
COLLAB_STORE_DB = COLLAB_STORE_DIR / "yjs.sqlite3"
4850

51+
# `gc.disable()` (see iris_engine.collab.pycrdt_worker, imported transitively via
52+
# the collab blueprint) is process-wide, so this ASGI process also needs its own
53+
# explicit collection. Safe to run directly on the event-loop thread here: unlike
54+
# the gunicorn worker, every pycrdt Doc/XmlFragment in this process is created and
55+
# torn down on the single asyncio event-loop thread (the `to_thread` calls below
56+
# only ever touch plain DB objects), so there is no cross-thread drop to guard
57+
# against with a dedicated worker thread.
58+
GC_INTERVAL_SECONDS = 60
59+
4960

5061
class AuthorizedRoom(NamedTuple):
5162
room_name: str
@@ -99,6 +110,7 @@ class IrisCollabASGIApp:
99110
def __init__(self, websocket_server: WebsocketServer):
100111
self._websocket_server = websocket_server
101112
self._server_task: Task | None = None
113+
self._gc_task: Task | None = None
102114

103115
async def __call__(
104116
self,
@@ -141,8 +153,12 @@ async def _handle_lifespan(
141153
COLLAB_STORE_DIR.mkdir(parents=True, exist_ok=True)
142154
self._server_task = create_task(self._websocket_server.start())
143155
await self._websocket_server.started.wait()
156+
self._gc_task = create_task(_periodic_gc())
144157
await send({"type": "lifespan.startup.complete"})
145158
elif message["type"] == "lifespan.shutdown":
159+
if self._gc_task is not None:
160+
self._gc_task.cancel()
161+
self._gc_task = None
146162
await self._websocket_server.stop()
147163
if self._server_task is not None:
148164
await self._server_task
@@ -183,6 +199,15 @@ async def _handle_http(
183199
await send({"type": "http.response.body", "body": body})
184200

185201

202+
async def _periodic_gc() -> None:
203+
try:
204+
while True:
205+
await asyncio.sleep(GC_INTERVAL_SECONDS)
206+
gc.collect()
207+
except asyncio.CancelledError:
208+
pass
209+
210+
186211
def authorize_scope(scope: dict[str, Any]) -> AuthorizedRoom | None:
187212
if not _check_origin(scope):
188213
origin = _get_header(scope, b"origin")
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# IRIS Source Code
2+
# Copyright (C) 2026 - DFIR-IRIS
3+
# contact@dfir-iris.org
4+
#
5+
# This program is free software; you can redistribute it and/or
6+
# modify it under the terms of the GNU Lesser General Public
7+
# License as published by the Free Software Foundation; either
8+
# version 3 of the License, or (at your option) any later version.
9+
10+
"""Confines all pycrdt (Yrs) work to a single dedicated thread.
11+
12+
pycrdt's Rust objects (`Doc`, `XmlFragment`, and the transaction/
13+
subscription handles pyo3 wraps around them) are `!Send`: yrs uses
14+
`Rc<RefCell<...>>` internally, so if Python's cyclic garbage collector
15+
drops one of these objects on a thread other than the one that created
16+
it, pyo3 raises:
17+
18+
RuntimeError: pycrdt::subscription::Subscription is unsendable,
19+
but is being dropped on another thread
20+
21+
Gunicorn runs this app under a `gthread` worker (many request-handling
22+
threads sharing one heap/GIL). CPython's *automatic* generational
23+
collector can fire from any thread the moment its allocation counter
24+
crosses a threshold — so a `Doc` created while resolving a note on
25+
thread A can get collected, and crash, while thread B is busy doing
26+
something completely unrelated (e.g. a SQLAlchemy query). That's why
27+
the traceback in production never mentions collab code.
28+
29+
Fix: disable automatic collection process-wide and route every pycrdt
30+
call, plus the `gc.collect()` that reclaims it, through this one
31+
worker thread. Objects are then always created and destroyed on the
32+
same thread, which is what pyo3 requires.
33+
"""
34+
from __future__ import annotations
35+
36+
import gc
37+
from concurrent.futures import ThreadPoolExecutor
38+
from typing import Callable, TypeVar
39+
40+
T = TypeVar("T")
41+
42+
# Automatic, threshold-triggered collection can run on any thread. Disabling
43+
# it process-wide is what makes the explicit gc.collect() below in
44+
# `_run_and_collect` (always on `_executor`'s single thread) the only place
45+
# cyclic garbage collection ever happens.
46+
gc.disable()
47+
48+
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="pycrdt-worker")
49+
50+
51+
def run(fn: Callable[..., T], *args, **kwargs) -> T:
52+
"""Run `fn(*args, **kwargs)` on the dedicated pycrdt thread and return its result.
53+
54+
Every pycrdt `Doc`/`XmlFragment` created inside `fn` is guaranteed to be
55+
garbage-collected on this same thread before `run()` returns.
56+
"""
57+
return _executor.submit(_run_and_collect, fn, args, kwargs).result()
58+
59+
60+
def _run_and_collect(fn, args, kwargs):
61+
try:
62+
return fn(*args, **kwargs)
63+
finally:
64+
gc.collect()

0 commit comments

Comments
 (0)