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
51 changes: 44 additions & 7 deletions deltachat-rpc-client/src/deltachat_rpc_client/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import contextlib
import itertools
import json
import logging
Expand Down Expand Up @@ -38,8 +39,15 @@ def future(self, *args) -> Any:
"params": args,
"id": request_id,
}
self.rpc.request_results[request_id] = queue = Queue()
self.rpc.request_queue.put(request)
queue: Queue = Queue()
# Register before testing for shutdown, so that either the reader loop
# finds this request while draining, or the test below catches it here.
# Testing first would race with the reader loop finishing in between.
self.rpc.request_results[request_id] = queue
if self.rpc.request_queue_closed:
self.rpc._fail_request(request_id)
else:
self.rpc.request_queue.put(request)

def rpc_future():
"""Wait for the request to receive a result."""
Expand Down Expand Up @@ -78,6 +86,10 @@ def __init__(
# Map from request ID to a Queue which provides a single result
self.request_results: dict[int, Queue]
self.request_queue: Queue[Any]
# Emulates `request_queue.shutdown(immediate=False)`, which needs Python 3.13:
# https://github.com/python/cpython/blob/v3.13.0/Lib/queue.py#L236-L257
# Note that `request_queue_closed` is set by the reader loop.
self.request_queue_closed: bool
self.closing: bool
self.reader_thread: Thread
self.writer_thread: Thread
Expand Down Expand Up @@ -107,6 +119,7 @@ def start(self) -> None:
self.event_queues = {}
self.request_results = {}
self.request_queue = Queue()
self.request_queue_closed = False
self.closing = False
self.reader_thread = Thread(target=self.reader_loop)
self.reader_thread.start()
Expand All @@ -123,6 +136,8 @@ def start(self) -> None:
# The reader_loop already saw EOF on stdout, so the process
# has exited and stderr is available.
stderr = self.process.stderr.read().decode(errors="replace").strip()
self.closing = True
self._shutdown_loops()
if stderr:
raise JsonRpcError(f"RPC server failed to start: {stderr}") from e
raise JsonRpcError(f"RPC server startup check failed: {e}") from e
Expand All @@ -135,11 +150,31 @@ def close(self) -> None:
"""Terminate RPC server process and wait until the reader loop finishes."""
self.closing = True
self.stop_io_for_all_accounts()
# Let `events_loop` stop cleanly on `closing` before the pipe goes away,
# otherwise it might exit through an "RPC server closed" error instead.
self.events_thread.join()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is another events_thread.join() inside of _shutdown_loops(), do we still need this join here? Also self.closing = True can be moved inside of _shutdown_loops, it is a signal for events_thread to stop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the join in close() is about ordering: it lets the events_loop stop on the closing flag before the pipe is torn down. Dropping it (with moving closing into _shutdown_loops) would work and never hang, but the events loop might call get_next_event_batch() and exit via RPC server closed, which then sprinkles ERROR ... Exception in the event loop. The double events_thread.join is a no-op for close so i think it's fine, and i added a comment to explain the ordering constraint.

self.process.stdin.close()
self.reader_thread.join()
self._shutdown_loops()

def _shutdown_loops(self) -> None:
"""Close the server pipe and wait for the loop threads to finish.

The writer blocks on an empty request queue,
so it needs the sentinel to notice the shutdown.
"""
with contextlib.suppress(BrokenPipeError):
# An exited server may leave data unflushed,
# which close() would try to write out again.
self.process.stdin.close()
self.request_queue.put(None)
self.reader_thread.join()
self.writer_thread.join()
self.events_thread.join()

def _fail_request(self, request_id: int) -> None:
"""Answer a registered request with an error, unless it was answered already."""
queue = self.request_results.pop(request_id, None)
if queue is not None:
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})

def __enter__(self):
self.start()
Expand All @@ -162,9 +197,11 @@ def reader_loop(self) -> None:
# Log an exception if the reader loop dies.
logging.exception("Exception in the reader loop")
finally:
# Unblock any pending requests when the server closes stdout.
for _request_id, queue in self.request_results.items():
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
# Shut the request queue first, so that requests registered from now
# on are failed by their caller, then answer the pending ones here.
self.request_queue_closed = True
for request_id in list(self.request_results):
self._fail_request(request_id)

def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests."""
Expand Down
5 changes: 5 additions & 0 deletions deltachat-rpc-client/tests/test_something.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,11 @@ def test_early_failure(tmp_path) -> None:
with pytest.raises(JsonRpcError, match="invalid_dir"):
rpc.start()

# Requests issued after the server exited must fail immediately
# instead of waiting forever for the finished reader loop.
with pytest.raises(JsonRpcError, match="RPC server closed"):
rpc.get_system_info()


def test_mdn_doesnt_break_autocrypt(acf) -> None:
alice, bob = acf.get_online_accounts(2)
Expand Down
Loading