Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
args: --release --target universal2-apple-darwin --out dist -i pypy3.11
- name: Test built wheel - universal2
run: |
pip install pytest "pydantic>=2.5.2,<3" "anyio>=4.4.0,<5" "trio>=0.25.1,<0.32"
pip install pytest "pydantic>=2.5.2,<3" "anyio>=4.14.0,<5" "trio>=0.25.1,<0.32"
pip install pycrdt --no-deps --no-index --find-links dist --force-reinstall
pytest --ignore tests/test_types.py
- name: Upload wheels
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
args: --release --features pyo3/abi3-py311 --out dist -i 3.11
- name: Test built wheel
run: |
pip install pytest "pydantic>=2.5.2,<3" "anyio>=4.4.0,<5" "trio>=0.25.1,<0.32"
pip install pytest "pydantic>=2.5.2,<3" "anyio>=4.14.0,<5" "trio>=0.25.1,<0.32"
pip install pycrdt --no-deps --no-index --find-links dist --force-reinstall
pytest --ignore tests/test_types.py
- name: Upload wheels
Expand Down Expand Up @@ -110,7 +110,7 @@ jobs:
- name: Test built wheel
if: matrix.target == 'x86_64'
run: |
pip install pytest "pydantic>=2.5.2,<3" "anyio>=4.4.0,<5" "trio>=0.25.1,<0.32"
pip install pytest "pydantic>=2.5.2,<3" "anyio>=4.14.0,<5" "trio>=0.25.1,<0.32"
pip install pycrdt --no-deps --no-index --find-links dist --force-reinstall
pytest --ignore tests/test_types.py
- name: Upload wheels
Expand Down Expand Up @@ -153,7 +153,7 @@ jobs:
install: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-pip
pip3 install --break-system-packages -U pytest "pydantic>=2.5.2,<3" "anyio>=4.4.0,<5" "trio>=0.25.1,<0.32"
pip3 install --break-system-packages -U pytest "pydantic>=2.5.2,<3" "anyio>=4.14.0,<5" "trio>=0.25.1,<0.32"
run: |
pip3 install --break-system-packages pycrdt --no-deps --no-index --find-links dist/ --force-reinstall
pytest --ignore tests/test_types.py
Expand Down Expand Up @@ -189,7 +189,7 @@ jobs:
run: |
docker run --rm -v "$(pwd):/workspace" -w /workspace alpine:latest sh -c "
apk add --no-cache python3 py3-pip &&
pip3 install --break-system-packages -U pip pytest 'pydantic>=2.5.2,<3' 'anyio>=4.4.0,<5' 'trio>=0.25.1,<0.32' &&
pip3 install --break-system-packages -U pip pytest 'pydantic>=2.5.2,<3' 'anyio>=4.14.0,<5' 'trio>=0.25.1,<0.32' &&
pip3 install --break-system-packages pycrdt --no-deps --no-index --find-links dist/ --force-reinstall &&
pytest --ignore tests/test_types.py
"
Expand Down Expand Up @@ -229,7 +229,7 @@ jobs:
githubToken: ${{ github.token }}
install: |
apk add --no-cache python3 py3-pip
pip3 install --break-system-packages -U pip pytest "pydantic>=2.5.2,<3" "anyio>=4.4.0,<5" "trio>=0.25.1,<0.32"
pip3 install --break-system-packages -U pip pytest "pydantic>=2.5.2,<3" "anyio>=4.14.0,<5" "trio>=0.25.1,<0.32"
run: |
pip3 install --break-system-packages pycrdt --no-deps --no-index --find-links dist/ --force-reinstall
pytest --ignore tests/test_types.py
Expand Down
1 change: 1 addition & 0 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Channel
- Decoder
- Doc
- UnsubscribableStream
- Encoder
- Map
- MapEvent
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ classifiers = [
]

dependencies = [
"anyio >=4.4.0,<5.0.0",
"anyio >=4.14.0,<5.0.0",
]

[dependency-groups]
Expand Down
1 change: 1 addition & 0 deletions python/pycrdt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from ._snapshot import Snapshot as Snapshot
from ._sticky_index import Assoc as Assoc
from ._sticky_index import StickyIndex as StickyIndex
from ._stream import UnsubscribableStream as UnsubscribableStream
from ._sync import Decoder as Decoder
from ._sync import Encoder as Encoder
from ._sync import YMessageType as YMessageType
Expand Down
38 changes: 23 additions & 15 deletions python/pycrdt/_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@
overload,
)

from anyio import BrokenResourceError, create_memory_object_stream
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from anyio import BrokenResourceError, ClosedResourceError, create_memory_object_stream
from anyio.streams.memory import MemoryObjectSendStream

from ._base import BaseDoc, BaseType, Typed, base_types, forbid_read_transaction
from ._pycrdt import Doc as _Doc
from ._pycrdt import SubdocsEvent, Subscription, TransactionEvent
from ._pycrdt import Transaction as _Transaction
from ._snapshot import Snapshot
from ._stream import UnsubscribableStream, _with_unsubscribe
from ._transaction import NewTransaction, ReadTransaction, Transaction

T = TypeVar("T", bound=BaseType)
Expand Down Expand Up @@ -384,15 +385,15 @@ def events(
subdocs: Literal[False] = False,
max_buffer_size: float = float("inf"),
async_transactions: bool = False,
) -> MemoryObjectReceiveStream[TransactionEvent]: ...
) -> UnsubscribableStream[TransactionEvent]: ...

@overload
def events(
self,
subdocs: Literal[True] = True,
max_buffer_size: float = float("inf"),
async_transactions: bool = False,
) -> MemoryObjectReceiveStream[list[SubdocsEvent]]: ...
) -> UnsubscribableStream[list[SubdocsEvent]]: ...

def events(
self,
Expand All @@ -415,6 +416,9 @@ async def main():
...
```

Call `events.unsubscribe()` to stop collecting events and drain the buffer
before iteration ends. Closing the receiving stream discards pending events.

Args:
subdocs: Whether to iterate over the [SubdocsEvent][pycrdt.SubdocsEvent] events
(default is [TransactionEvent][pycrdt.TransactionEvent]).
Expand All @@ -438,7 +442,17 @@ async def main():
Union[TransactionEvent, SubdocsEvent]
](max_buffer_size=max_buffer_size)
self._send_streams[subdocs].add(send_stream)
return receive_stream
return _with_unsubscribe(
receive_stream, partial(self._unsubscribe_events, subdocs, send_stream)
)

def _unsubscribe_events(self, subdocs, send_stream):
send_streams = self._send_streams[subdocs]
if send_stream in send_streams:
send_streams.remove(send_stream)
send_stream.close()
if not send_streams:
self.unobserve(self._event_subscription.pop(subdocs))

def _send_event(self, subdocs: bool, event: TransactionEvent | SubdocsEvent):
to_remove: list[MemoryObjectSendStream[TransactionEvent | SubdocsEvent]] = []
Expand All @@ -449,24 +463,18 @@ def _send_event(self, subdocs: bool, event: TransactionEvent | SubdocsEvent):
except BrokenResourceError:
to_remove.append(send_stream)
for send_stream in to_remove:
send_stream.close()
send_streams.remove(send_stream)
if not send_streams:
self.unobserve(self._event_subscription[subdocs])
self._unsubscribe_events(subdocs, send_stream)

async def _async_send_event(self, subdocs: bool, event: TransactionEvent | SubdocsEvent):
to_remove: list[MemoryObjectSendStream[TransactionEvent | SubdocsEvent]] = []
send_streams = self._send_streams[subdocs]
for send_stream in send_streams:
for send_stream in tuple(send_streams):
try:
await send_stream.send(event)
except BrokenResourceError:
except (BrokenResourceError, ClosedResourceError):
to_remove.append(send_stream)
for send_stream in to_remove:
send_stream.close()
send_streams.remove(send_stream)
if not send_streams:
self.unobserve(self._event_subscription[subdocs])
self._unsubscribe_events(subdocs, send_stream)


class TypedDoc(Typed):
Expand Down
57 changes: 42 additions & 15 deletions python/pycrdt/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

from contextlib import AsyncExitStack, asynccontextmanager
from logging import Logger, getLogger
from sys import exc_info
from typing import AsyncIterator, Protocol

from anyio import (
TASK_STATUS_IGNORED,
Event,
Lock,
TaskHandle,
create_task_group,
)
from anyio.abc import TaskGroup, TaskStatus

from ._doc import Doc
from ._pycrdt import TransactionEvent
from ._stream import UnsubscribableStream
from ._sync import (
YMessageType,
YSyncMessageType,
Expand Down Expand Up @@ -83,6 +87,10 @@ def __init__(self, doc: Doc, channel: Channel, log: Logger | None = None) -> Non
The `synced` event is set when initial synchronization with the remote
peer is complete. Later changes continue to synchronize in the background.

On normal context exit, queued updates finish sending through the channel
before the provider stops. Exceptions and cancellation stop it immediately.
Channel sends do not confirm receipt or persistence by the remote peer.

However, a lower-level API can also be used:
```py
task = asyncio.create_task(provider.start())
Expand All @@ -103,17 +111,10 @@ def __init__(self, doc: Doc, channel: Channel, log: Logger | None = None) -> Non
self.synced = Event()
self._start_lock = Lock()
self._task_group: TaskGroup | None = None
self._events: UnsubscribableStream[TransactionEvent] | None = None
self._updates_task: TaskHandle[None] | None = None

async def _run(self):
sync_message = create_sync_message(self._doc)
self.log.debug(
"Sending %s message to endpoint: %s",
YSyncMessageType.SYNC_STEP1.name,
self._channel.path,
)
await self._channel.send(sync_message)
assert self._task_group is not None
self._task_group.start_soon(self._send_updates)
async for message in self._channel:
if message[0] == YMessageType.SYNC:
self.log.debug(
Expand All @@ -132,11 +133,22 @@ async def _run(self):
)
await self._channel.send(reply)

async def _send_updates(self):
async with self._doc.events() as events:
async for event in events:
message = create_update_message(event.update)
await self._channel.send(message)
async def _send_updates(self) -> None:
assert self._events is not None
async with self._events as events:
try:
sync_message = create_sync_message(self._doc)
self.log.debug(
"Sending %s message to endpoint: %s",
YSyncMessageType.SYNC_STEP1.name,
self._channel.path,
)
await self._channel.send(sync_message)
async for event in events:
message = create_update_message(event.update)
await self._channel.send(message)
finally:
events.unsubscribe()

async def __aenter__(self) -> Provider:
async with AsyncExitStack() as exit_stack:
Expand All @@ -148,6 +160,16 @@ async def __aenter__(self) -> Provider:
return self

async def __aexit__(self, exc_type, exc_value, exc_tb):
if exc_type is None:
try:
assert self._events is not None
self._events.unsubscribe()
assert self._updates_task is not None
await self._updates_task
except BaseException:
await self.stop()
await self._exit_stack.__aexit__(*exc_info())
raise
await self.stop()
return await self._exit_stack.__aexit__(exc_type, exc_value, exc_tb)

Expand All @@ -172,12 +194,17 @@ async def start(
"""
async with self._start_lock:
async with self._get_or_create_task_group() as self._task_group:
self._events = self._doc.events()
self._updates_task = self._task_group.start_soon(self._send_updates)
task_status.started()
self.started.set()
self._task_group.start_soon(self._run)

async def stop(self) -> None:
"""Stop the provider."""
"""Stop the provider immediately, discarding pending updates."""
assert self._task_group is not None
assert self._events is not None
self._events.unsubscribe()
self._events.close()
self._task_group.cancel_scope.cancel()
self._task_group = None
32 changes: 32 additions & 0 deletions python/pycrdt/_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Callable, TypeVar, cast

from anyio.streams.memory import MemoryObjectReceiveStream

E = TypeVar("E")


class UnsubscribableStream(MemoryObjectReceiveStream[E]):
"""A receive stream with an unsubscribe method.

Unsubscribing stops collecting items while leaving buffered items available
to receive. Iteration ends once the buffer is drained.
"""

unsubscribe: Callable[[], None]
"""Stop collecting items. Repeated calls have no effect."""

if TYPE_CHECKING:

def clone(self) -> UnsubscribableStream[E]: ...


def _with_unsubscribe(
stream: MemoryObjectReceiveStream[E], unsubscribe: Callable[[], None]
) -> UnsubscribableStream[E]:
stream = cast(UnsubscribableStream[E], stream)
stream.unsubscribe = unsubscribe
original_clone = stream.clone
stream.clone = lambda: _with_unsubscribe(original_clone(), unsubscribe) # type: ignore[method-assign]
Comment on lines +30 to +31

@armanckeser armanckeser Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this implementation causes a memory "leak" because the clone does not use a weakref.

  • stream.clone = partial(_clone, ref(stream), unsubscribe)

might want to switch to something like this @davidbrochart

return stream
Loading
Loading