Skip to content
Open
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
1 change: 1 addition & 0 deletions changelog.d/18903.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Wrap the Rust HTTP client with `make_deferred_yieldable` so it follows Synapse logcontext rules.
16 changes: 7 additions & 9 deletions synapse/api/auth/mas.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,14 @@
UnrecognizedRequestError,
)
from synapse.http.site import SynapseRequest
from synapse.logging.context import PreserveLoggingContext
from synapse.logging.opentracing import (
active_span,
force_tracing,
inject_request_headers,
start_active_span,
)
from synapse.metrics import SERVER_NAME_LABEL
from synapse.synapse_rust.http_client import HttpClient
from synapse.synapse_rust_wrapper.http_client import HttpClient
from synapse.types import JsonDict, Requester, UserID, create_requester
from synapse.util import json_decoder
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
Expand Down Expand Up @@ -229,13 +228,12 @@ async def _introspect_token(
try:
with start_active_span("mas-introspect-token"):
inject_request_headers(raw_headers)
with PreserveLoggingContext():
resp_body = await self._rust_http_client.post(
url=self._introspection_endpoint,
response_limit=1 * 1024 * 1024,
headers=raw_headers,
request_body=body,
)
resp_body = await self._rust_http_client.post(
url=self._introspection_endpoint,
response_limit=1 * 1024 * 1024,
headers=raw_headers,
request_body=body,
)
except HttpResponseException as e:
end_time = self._clock.time()
introspection_response_timer.labels(
Expand Down
16 changes: 7 additions & 9 deletions synapse/api/auth/msc3861_delegated.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,14 @@
UnrecognizedRequestError,
)
from synapse.http.site import SynapseRequest
from synapse.logging.context import PreserveLoggingContext
from synapse.logging.opentracing import (
active_span,
force_tracing,
inject_request_headers,
start_active_span,
)
from synapse.metrics import SERVER_NAME_LABEL
from synapse.synapse_rust.http_client import HttpClient
from synapse.synapse_rust_wrapper.http_client import HttpClient
from synapse.types import Requester, UserID, create_requester
from synapse.util import json_decoder
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
Expand Down Expand Up @@ -327,13 +326,12 @@ async def _introspect_token(
try:
with start_active_span("mas-introspect-token"):
inject_request_headers(raw_headers)
with PreserveLoggingContext():
resp_body = await self._rust_http_client.post(
url=uri,
response_limit=1 * 1024 * 1024,
headers=raw_headers,
request_body=body,
)
resp_body = await self._rust_http_client.post(
url=uri,
response_limit=1 * 1024 * 1024,
headers=raw_headers,
request_body=body,
)
except HttpResponseException as e:
end_time = self._clock.time()
introspection_response_timer.labels(
Expand Down
7 changes: 7 additions & 0 deletions synapse/synapse_rust/http_client.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ from twisted.internet.defer import Deferred
from synapse.types import ISynapseReactor

class HttpClient:
"""
Since the returned deferreds don't follow Synapse logcontext rules,
this is not meant to be used by Synapse code directly.

Use `synapse.synapse_rust_wrapper.http_client.HttpClient` instead.
"""

def __init__(self, reactor: ISynapseReactor, user_agent: str) -> None: ...
def get(self, url: str, response_limit: int) -> Deferred[bytes]: ...
def post(
Expand Down
11 changes: 11 additions & 0 deletions synapse/synapse_rust_wrapper/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
44 changes: 44 additions & 0 deletions synapse/synapse_rust_wrapper/http_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.


from typing import Mapping

from twisted.internet.defer import Deferred

from synapse.logging.context import make_deferred_yieldable
from synapse.synapse_rust.http_client import HttpClient as RustHttpClient
from synapse.types import ISynapseReactor


class HttpClient:
Copy link
Contributor Author

@MadLittleMods MadLittleMods Sep 9, 2025

Choose a reason for hiding this comment

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

I think this solution is awful.

What's a better way we can wrap the Rust code to do the right thing?

Call Python from the Rust code?

Write an equivalent wrapper in Rust? (not sure how to approach)

Better way to organize all of this? I initially tried to rename http_client.pyi to internal_http_client.pyi and add a new http_client.py with this wrapper all within synapse/synapse_rust/ but the native Python doesn't get picked up.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think calling make_deferred_yieldable from the Rust side (and therefore not having the wrapper) would be a reasonable alternative, but I also don't think this wrapper approach is too bad — at least it's extremely clear. (Maybe a bit easy to forget about using it?)

If you want a sketch of calling Python code from Rust, here's how I called _get_size_of for my caching fun, whilst only importing it once:

static GETSIZEOF: OnceLock<pyo3::Py<pyo3::PyAny>> = OnceLock::new();

fn get_size_of(py: Python<'_>, obj: &Bound<'_, PyAny>) -> u64 {
    let getsizeof = GETSIZEOF.get_or_init(|| {
        let sys = PyModule::import(py, "synapse.util.caches.lrucache").unwrap();
        let func = sys.getattr("_get_size_of").unwrap().unbind();
        func
    });

    let size: u64 = getsizeof.call1(py, (obj,)).unwrap().extract(py).unwrap();
    size
}

Copy link
Member

Choose a reason for hiding this comment

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

One thing I did when I did the pyo3-twisted bridge POC was to store and restore the contextvars during the future execution. If we switched to storing the logging context in contextvars, it would solve the logcontext being lost, but maybe not the resource usage metering. You can still probably take inspiration from that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think calling make_deferred_yieldable from the Rust side (and therefore not having the wrapper) would be a reasonable alternative

This would be my choice if we can get it working.

(haven't tried yet since I'm trying to write a test to exercise this part of the code easily, #18903 (comment))

"""
Wrap `synapse.synapse_rust.http_client.HttpClient` to ensure the returned
deferreds follow Synapse logcontext rules.
"""

def __init__(self, reactor: ISynapseReactor, user_agent: str) -> None:
self._http_client = RustHttpClient(reactor, user_agent)

def get(self, url: str, response_limit: int) -> Deferred[bytes]:
deferred = self._http_client.get(url, response_limit)
return make_deferred_yieldable(deferred)

def post(
self,
url: str,
response_limit: int,
headers: Mapping[str, str],
request_body: str,
) -> Deferred[bytes]:
deferred = self._http_client.post(url, response_limit, headers, request_body)
return make_deferred_yieldable(deferred)
11 changes: 11 additions & 0 deletions tests/synapse_rust/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
87 changes: 87 additions & 0 deletions tests/synapse_rust/test_http_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.

import time
from typing import Any, Coroutine, Generator, TypeVar, Union

from twisted.internet.defer import Deferred, ensureDeferred
from twisted.internet.testing import MemoryReactor

from synapse.logging.context import LoggingContext
from synapse.server import HomeServer
from synapse.synapse_rust.http_client import HttpClient
from synapse.util import Clock

from tests.unittest import HomeserverTestCase

T = TypeVar("T")


class HttpClientTestCase(HomeserverTestCase):
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
hs = self.setup_test_homeserver()
# This triggers the server startup hooks, which starts the Tokio thread pool
reactor.run()
Comment on lines +32 to +33
Copy link
Contributor Author

@MadLittleMods MadLittleMods Sep 9, 2025

Choose a reason for hiding this comment

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

I'm trying to add a test for this but running into Tokio runtime is not running

$ SYNAPSE_TEST_LOG_LEVEL=INFO poetry run trial tests.synapse_rust.test_http_client
The "poetry.dev-dependencies" section is deprecated and will be removed in a future version. Use "poetry.group.dev.dependencies" instead.
tests.synapse_rust.test_http_client
  HttpClientTestCase
    test_logging_context ...                                             [FAIL]

===============================================================================
[FAIL]
Traceback (most recent call last):
  File "synapse/tests/synapse_rust/test_http_client.py", line 87, in test_logging_context
    self.get_success(self.till_deferred_has_result(asdf()))
  File "synapse/tests/unittest.py", line 693, in get_success
    return self.successResultOf(deferred)
  File "virtualenvs/matrix-synapse-xCtC9ulO-py3.13/lib/python3.13/site-packages/twisted/trial/_synctest.py", line 732, in successResultOf
    self.fail(
twisted.trial.unittest.FailTest: Success result expected on <Deferred at 0x7f928e6f5010 current result: None>, found failure result instead:
Traceback (most recent call last):
  File "virtualenvs/matrix-synapse-xCtC9ulO-py3.13/lib/python3.13/site-packages/twisted/internet/defer.py", line 1857, in _inlineCallbacks
    result = context.run(gen.send, result)
  File "synapse/tests/synapse_rust/test_http_client.py", line 82, in asdf
    await self._rust_http_client.get(
builtins.RuntimeError: Tokio runtime is not running


tests.synapse_rust.test_http_client.HttpClientTestCase.test_logging_context
-------------------------------------------------------------------------------
Ran 1 tests in 0.146s

FAILED (failures=1)

I can see that the tokio runtime is supposed to start when the Twisted reactor starts:

// Attach the runtime to the reactor, starting it when the reactor is
// running, stopping it when the reactor is shutting down
reactor.call_method1("callWhenRunning", (runtime.getattr("start")?,))?;

(introduced in #18691)

If I add in my own hook in the test itself hs.get_reactor().callWhenRunning(print, "asdf"), it prints.

My test is based off of the existing MasAuthDelegation tests but those actually seem to work. Perhaps because the Rust action is happening within the Synapse code instead of in the test code where there might be enough indirection delay for tokio to startup fully.

I can't figure out how to wait that little bit longer for tokio to startup.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@sandhose Any insight? 🙇

Copy link
Member

Choose a reason for hiding this comment

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

For some reason, creating the Rust HTTP client just before the reactor.run within make_homeserver… which I don't get, because the MasAuthDelegation test calls hs.get_auth() (which instantiates the Rust HTTP client) right after reactor.run

return hs

def tearDown(self) -> None:
# MemoryReactor doesn't trigger the shutdown phases, and we want the
# Tokio thread pool to be stopped
# XXX: This logic should probably get moved somewhere else
shutdown_triggers = self.reactor.triggers.get("shutdown", {})
for phase in ["before", "during", "after"]:
triggers = shutdown_triggers.get(phase, [])
for callbable, args, kwargs in triggers:
callbable(*args, **kwargs)

def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self._http_client = hs.get_proxied_http_client()
self._rust_http_client = HttpClient(
reactor=hs.get_reactor(),
user_agent=self._http_client.user_agent.decode("utf8"),
)

def till_deferred_has_result(
self,
awaitable: Union[
"Coroutine[Deferred[Any], Any, T]",
"Generator[Deferred[Any], Any, T]",
"Deferred[T]",
],
) -> "Deferred[T]":
"""Wait until a deferred has a result.

This is useful because the Rust HTTP client will resolve the deferred
using reactor.callFromThread, which are only run when we call
reactor.advance.
"""
deferred = ensureDeferred(awaitable)
tries = 0
while not deferred.called:
time.sleep(0.1)
self.reactor.advance(0)
tries += 1
if tries > 100:
raise Exception("Timed out waiting for deferred to resolve")

return deferred

def test_logging_context(self) -> None:
async def asdf() -> None:
with LoggingContext("test"):
# TODO: Test logging context before/after this call
await self._rust_http_client.get(
url="http://localhost",
response_limit=1 * 1024 * 1024,
)

self.get_success(self.till_deferred_has_result(asdf()))
Loading