Add a typed HomeServer wrapper on the Rust side

Same pattern as the Reactor wrapper: a newtype around the Python object
that is the single place naming the HomeServer API surface Rust relies on
(config extraction, get_rust_runtime, get_clock, the main database pool).
RustHandlers and both rendezvous handlers now take it instead of a bare
Py<PyAny>, and public_baseurl moves into the extracted ServerConfig view
rather than a getattr chain.

Also drop the runtime duck-type validation from Reactor: hasattr only
proves a name exists, not that the signature matches, and the Python side
of these boundaries is already type-checked by mypy via the .pyi stubs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyuiV3m44ZbK9o24EnMMES
This commit is contained in:
Erik Johnston
2026-07-28 09:08:21 +00:00
co-authored by Claude Opus 5
parent 00c35955ee
commit efb0e1cce4
7 changed files with 109 additions and 68 deletions
+1
View File
@@ -48,6 +48,7 @@ pub struct AuthConfig {
#[derive(FromPyObject, Clone)]
pub struct ServerConfig {
pub msc4140_enabled: bool,
pub public_baseurl: String,
}
#[derive(FromPyObject, Clone)]
+8 -14
View File
@@ -21,8 +21,7 @@ use pyo3::{
Bound, PyResult, Python,
};
use crate::config::SynapseHomeServerConfig;
use crate::runtime::RustRuntime;
use crate::homeserver::HomeServer;
use crate::storage::db::python_db_pool::PythonDatabasePoolWrapper;
use crate::storage::store::Store;
@@ -37,22 +36,17 @@ struct RustHandlers {
impl RustHandlers {
#[new]
#[pyo3(signature = (homeserver))]
pub fn py_new(py: Python<'_>, homeserver: &Bound<'_, PyAny>) -> PyResult<RustHandlers> {
let config: SynapseHomeServerConfig = homeserver.getattr("config")?.extract()?;
pub fn py_new(py: Python<'_>, homeserver: HomeServer) -> PyResult<RustHandlers> {
let config = homeserver.config(py)?;
// The per-homeserver Rust state, which gives us the tokio runtime
// and the Twisted reactor.
let runtime: Bound<'_, RustRuntime> =
homeserver.call_method0("get_rust_runtime")?.extract()?;
let runtime = Arc::clone(runtime.get().inner());
let runtime = homeserver.rust_runtime(py)?;
// hs.get_datastores().main.db_pool
let db_pool_py = homeserver
.call_method0("get_datastores")?
.getattr("main")?
.getattr("db_pool")?;
let db_pool =
PythonDatabasePoolWrapper::new(&db_pool_py, runtime.reactor().clone_ref(py))?;
let db_pool = PythonDatabasePoolWrapper::new(
&homeserver.main_database_pool(py)?,
runtime.reactor().clone_ref(py),
)?;
// Store is shared across all of the handlers so let's use an `Arc`
let store = Arc::new(Store {
+77
View File
@@ -0,0 +1,77 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations 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>.
*
*/
//! A typed wrapper around the Python `HomeServer`.
use std::sync::Arc;
use pyo3::{intern, prelude::*};
use crate::config::SynapseHomeServerConfig;
use crate::runtime::{RustRuntime, RustRuntimeInner};
/// The Python `HomeServer`, as seen from Rust.
///
/// Like [`crate::reactor::Reactor`], this is a typed facade over a foreign
/// Python object: it does no validation of the object it wraps (the Python
/// side is type-checked by mypy), but it is the single place that names the
/// `HomeServer` API surface that Rust code depends on.
pub struct HomeServer(Py<PyAny>);
impl<'a, 'py> FromPyObject<'a, 'py> for HomeServer {
type Error = PyErr;
fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
Ok(HomeServer(obj.to_owned().unbind()))
}
}
impl HomeServer {
/// The per-homeserver Rust state (`hs.get_rust_runtime()`), which gives
/// access to the tokio runtime and the reactor.
pub fn rust_runtime(&self, py: Python<'_>) -> PyResult<Arc<RustRuntimeInner>> {
let runtime: Bound<'_, RustRuntime> = self
.0
.bind(py)
.call_method0(intern!(py, "get_rust_runtime"))?
.extract()?;
Ok(Arc::clone(runtime.get().inner()))
}
/// The Rust-side view of `hs.config`.
pub fn config(&self, py: Python<'_>) -> PyResult<SynapseHomeServerConfig> {
self.0.bind(py).getattr(intern!(py, "config"))?.extract()
}
/// The Synapse `Clock` (`hs.get_clock()`).
// TODO: give the clock a typed wrapper of its own.
pub fn clock(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
Ok(self
.0
.bind(py)
.call_method0(intern!(py, "get_clock"))?
.unbind())
}
/// The main database pool (`hs.get_datastores().main.db_pool`).
pub fn main_database_pool<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
self.0
.bind(py)
.call_method0(intern!(py, "get_datastores"))?
.getattr(intern!(py, "main"))?
.getattr(intern!(py, "db_pool"))
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod duration;
pub mod errors;
pub mod events;
pub mod handlers;
pub mod homeserver;
pub mod http;
pub mod http_client;
pub mod identifier;
+8 -14
View File
@@ -21,7 +21,7 @@ use http::StatusCode;
use pyo3::{
pyclass, pymethods,
types::{PyAnyMethods, PyModule, PyModuleMethods},
Bound, IntoPyObject, Py, PyAny, PyResult, Python,
Bound, Py, PyAny, PyResult, Python,
};
use serde::Deserialize;
use ulid::Ulid;
@@ -30,9 +30,9 @@ use self::session::Session;
use crate::{
duration::SynapseDuration,
errors::{NotFoundError, SynapseError},
homeserver::HomeServer,
http::http_request_from_twisted,
msc4388_rendezvous::session::{GetResponse, PostResponse, PutResponse},
UnwrapInfallible,
};
mod session;
@@ -92,25 +92,21 @@ impl MSC4388RendezvousHandler {
#[pyo3(signature = (homeserver, /, soft_limit=100, hard_limit=200,max_content_length=4*1024, eviction_interval=60*1000, ttl=2*60*1000))]
fn new(
py: Python<'_>,
homeserver: &Bound<'_, PyAny>,
homeserver: HomeServer,
soft_limit: usize,
hard_limit: usize,
max_content_length: u64,
eviction_interval: u64,
ttl: u64,
) -> PyResult<Py<Self>> {
let clock = homeserver
.call_method0("get_clock")?
.into_pyobject(py)
.unwrap_infallible()
.unbind();
let clock = homeserver.clock(py)?;
// Construct a Python object so that we can get a reference to the
// evict method and schedule it to run.
let self_ = Py::new(
py,
Self {
clock,
clock: clock.clone_ref(py),
sessions: BTreeMap::new(),
soft_limit,
hard_limit,
@@ -122,11 +118,9 @@ impl MSC4388RendezvousHandler {
let eviction_duration = SynapseDuration::from_milliseconds(eviction_interval);
let evict = self_.getattr(py, "_evict")?;
homeserver.call_method0("get_clock")?.call_method(
"looping_call",
(evict, &eviction_duration),
None,
)?;
clock
.bind(py)
.call_method("looping_call", (evict, &eviction_duration), None)?;
Ok(self_)
}
+5 -21
View File
@@ -15,36 +15,20 @@
//! A typed wrapper around the Twisted reactor.
use pyo3::{call::PyCallArgs, exceptions::PyTypeError, intern, prelude::*};
/// The reactor methods Rust code relies on.
///
/// Extracting a [`Reactor`] from a Python object fails if any of these are
/// missing, so mistakes surface as a `TypeError` at the FFI boundary rather
/// than as an `AttributeError` on a tokio worker thread later.
const REQUIRED_METHODS: &[&str] = &["callFromThread", "addSystemEventTrigger"];
use pyo3::{call::PyCallArgs, intern, prelude::*};
/// The Twisted reactor, as seen from Rust.
///
/// This is not a static guarantee that the object behaves like a reactor (it
/// is a foreign Python object), but the duck type is checked once at
/// extraction, and this module is the single place that names the Twisted
/// API surface that Rust code depends on.
/// A typed facade over a foreign Python object: it does no validation of the
/// object it wraps (the Python side is type-checked by mypy), but it is the
/// single place that names the Twisted API surface that Rust code depends
/// on.
pub struct Reactor(Py<PyAny>);
impl<'a, 'py> FromPyObject<'a, 'py> for Reactor {
type Error = PyErr;
fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
for name in REQUIRED_METHODS {
if !obj.hasattr(*name)? {
return Err(PyTypeError::new_err(format!(
"expected a Twisted reactor, but {} has no `{name}` method",
obj.get_type()
)));
}
}
Ok(Reactor(obj.to_owned().unbind()))
}
}
+9 -19
View File
@@ -29,7 +29,7 @@ use pyo3::{
exceptions::PyValueError,
pyclass, pymethods,
types::{PyAnyMethods, PyModule, PyModuleMethods},
Bound, IntoPyObject, Py, PyAny, PyResult, Python,
Bound, Py, PyAny, PyResult, Python,
};
use ulid::Ulid;
@@ -37,8 +37,8 @@ use self::session::Session;
use crate::{
duration::SynapseDuration,
errors::{NotFoundError, SynapseError},
homeserver::HomeServer,
http::{http_request_from_twisted, http_response_to_twisted, HeaderMapPyExt},
UnwrapInfallible,
};
mod session;
@@ -113,25 +113,17 @@ impl RendezvousHandler {
#[pyo3(signature = (homeserver, /, capacity=100, max_content_length=4*1024, eviction_interval=60*1000, ttl=60*1000))]
fn new(
py: Python<'_>,
homeserver: &Bound<'_, PyAny>,
homeserver: HomeServer,
capacity: usize,
max_content_length: u64,
eviction_interval: u64,
ttl: u64,
) -> PyResult<Py<Self>> {
let base: String = homeserver
.getattr("config")?
.getattr("server")?
.getattr("public_baseurl")?
.extract()?;
let base = homeserver.config(py)?.server.public_baseurl;
let base = Uri::try_from(format!("{base}_synapse/client/rendezvous"))
.map_err(|_| PyValueError::new_err("Invalid base URI"))?;
let clock = homeserver
.call_method0("get_clock")?
.into_pyobject(py)
.unwrap_infallible()
.unbind();
let clock = homeserver.clock(py)?;
let eviction_duration = SynapseDuration::from_milliseconds(eviction_interval);
@@ -141,7 +133,7 @@ impl RendezvousHandler {
py,
Self {
base,
clock,
clock: clock.clone_ref(py),
sessions: BTreeMap::new(),
capacity,
max_content_length,
@@ -150,11 +142,9 @@ impl RendezvousHandler {
)?;
let evict = self_.getattr(py, "_evict")?;
homeserver.call_method0("get_clock")?.call_method(
"looping_call",
(evict, &eviction_duration),
None,
)?;
clock
.bind(py)
.call_method("looping_call", (evict, &eviction_duration), None)?;
Ok(self_)
}