Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use futures::{channel::oneshot, future::FutureExt, io::BufReader, AsyncRead, AsyncWrite};
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
use crate::{platform::client::connect_client, protocol::Request};
|
||||
|
||||
use super::{
|
||||
protocol::{
|
||||
receive_message, send_message, ConnectionAddress, ProtocolError, RequestId, Response,
|
||||
},
|
||||
service::service_id,
|
||||
Service,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InitializationError {
|
||||
Io(std::io::Error),
|
||||
UnsupportedPlatform,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ClientError {
|
||||
#[error("Failed to initialize client: {0:?}")]
|
||||
Initialization(InitializationError),
|
||||
|
||||
#[error("Connection was dropped.")]
|
||||
Disconnected,
|
||||
|
||||
#[error("Internal error occurred: {0:?}")]
|
||||
InternalProtocol(#[from] ProtocolError),
|
||||
|
||||
#[error("The channel for receiving the response from the inbound message task is closed.")]
|
||||
ResponseChannelClosed,
|
||||
|
||||
#[error(
|
||||
"The channel for transmitting pending request info to the inbound message task is closed."
|
||||
)]
|
||||
PendingRequestInfoChannelClosed,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ClientError>;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingRequestInfo {
|
||||
/// The ID of the in-flight request.
|
||||
request_id: RequestId,
|
||||
|
||||
/// A sender for relaying the response bytes back to the caller of `send_request()`.
|
||||
response_result_tx: oneshot::Sender<Result<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct OutboundRequest {
|
||||
// The request to be sent to the server.
|
||||
request: Request,
|
||||
|
||||
// A sender for relaying any error that occurs when sending the request.
|
||||
//
|
||||
// If the request is sent successfully, this sender is moved to the new `PendingRequestInfo`
|
||||
// created for the request, where it is eventually used to relay response bytes back to the
|
||||
// caller.
|
||||
response_result_tx: oneshot::Sender<Result<Vec<u8>>>,
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
/// A sender for relaying requests from `Self::send_request()` to the background task
|
||||
/// responsible for actually writing requests to the socket.
|
||||
outbound_message_tx: async_channel::Sender<OutboundRequest>,
|
||||
|
||||
/// A receiver for a single-message bounded channel that emits an event when the server
|
||||
/// connection is dropped.
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
|
||||
/// A reference to the background executor so that we don't drop it while waiting on tasks
|
||||
/// that use it to run to completion. Otherwise, it can hang when all references are dropped.
|
||||
_background_executor: Arc<Background>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Creates a client connected to a server corresponding to the given `connection_address`.
|
||||
///
|
||||
/// If successful, spawns background tasks to send requests and receive responses.
|
||||
pub async fn connect(
|
||||
connection_address: ConnectionAddress,
|
||||
background_executor: Arc<Background>,
|
||||
) -> Result<Self> {
|
||||
let (reader, writer) = connect_client(connection_address).await?;
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::bounded(1);
|
||||
let (pending_request_info_tx, pending_request_info_rx) = async_channel::unbounded();
|
||||
let disconnect_tx_clone = disconnect_tx.clone();
|
||||
background_executor
|
||||
.spawn(async move {
|
||||
Self::handle_incoming_responses(reader, pending_request_info_rx).await;
|
||||
let _ = disconnect_tx_clone.try_send(());
|
||||
})
|
||||
.detach();
|
||||
|
||||
let (outbound_message_tx, outbound_message_rx) = async_channel::unbounded();
|
||||
background_executor
|
||||
.spawn(async move {
|
||||
Self::handle_outgoing_requests(
|
||||
writer,
|
||||
outbound_message_rx,
|
||||
pending_request_info_tx,
|
||||
)
|
||||
.await;
|
||||
let _ = disconnect_tx.try_send(());
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(Self {
|
||||
outbound_message_tx,
|
||||
disconnect_rx,
|
||||
_background_executor: background_executor,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn wait_for_disconnect(&self) {
|
||||
let _ = self.disconnect_rx.recv().await;
|
||||
}
|
||||
|
||||
/// Schedules the given message to be written to the underlying transport.
|
||||
pub(super) async fn send_request<S: Service>(&self, request_bytes: Vec<u8>) -> Result<Vec<u8>> {
|
||||
let request = Request::new(service_id::<S>(), request_bytes);
|
||||
|
||||
// Create a channel for the response result. The sending end is sent to the outbound
|
||||
// message task. The outbound meessage task uses it to relay any error that might occur
|
||||
// when sending the message. If the message is sent successfully, the sending end is
|
||||
// forwarded to the _inbound_ message task, which will eventually use it to relay the
|
||||
// response bytes.
|
||||
let (response_result_tx, response_result_rx) = oneshot::channel();
|
||||
|
||||
if self
|
||||
.outbound_message_tx
|
||||
.send(OutboundRequest {
|
||||
request,
|
||||
response_result_tx,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// The background inbound traffic processing task exited, so we must be disconnected.
|
||||
return Err(ClientError::Disconnected);
|
||||
}
|
||||
|
||||
match response_result_rx.await {
|
||||
Ok(response_result) => response_result,
|
||||
Err(_) => Err(ClientError::ResponseChannelClosed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles incoming response messages and relays them back to the caller via a
|
||||
/// request-specific async channel.
|
||||
async fn handle_incoming_responses(
|
||||
reader: impl AsyncRead + Unpin,
|
||||
pending_request_info_rx: async_channel::Receiver<PendingRequestInfo>,
|
||||
) {
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// Map from request ID to async channel sender, through which we should relay the
|
||||
// corresponding response bytes.
|
||||
let mut response_senders = HashMap::<RequestId, oneshot::Sender<Result<Vec<u8>>>>::new();
|
||||
|
||||
loop {
|
||||
futures::select! {
|
||||
pending_request_info = pending_request_info_rx.recv().fuse() => {
|
||||
// TODO(zachbai): Because we're asynchronously receiving `PendingRequestInfo`
|
||||
// from the outbound request task, it's possible that the response is actually
|
||||
// received before the pending_request_info is received and handled by this
|
||||
// block. We should hold onto unmatched responses for some small amount of time
|
||||
// and check if new `PendingRequestInfo`s match the recently received responses.
|
||||
// Similarly, its possible the server never responds to a request with a
|
||||
// `PendingRequestInfo` -- we should implement timed cleanups of
|
||||
// `PendingRequestInfo` (a request timeout) to address the possible memory leak.
|
||||
match pending_request_info {
|
||||
Ok(PendingRequestInfo {
|
||||
request_id, response_result_tx
|
||||
}) => {
|
||||
// We've just sent a request, so update the `response_senders` map
|
||||
// so we can relay the response back.
|
||||
response_senders.insert(request_id, response_result_tx);
|
||||
}
|
||||
Err(_) => {
|
||||
// This happens when the channel is closed, which implies the client
|
||||
// was `Drop`ped, so break and exit.
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
response = receive_message(&mut reader).fuse() => {
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let (request_id, response_result) = match response {
|
||||
Response::Success {
|
||||
request_id,
|
||||
bytes: response_bytes,
|
||||
..
|
||||
} => {
|
||||
(request_id, Ok(response_bytes))
|
||||
}
|
||||
Response::Failure {
|
||||
request_id,
|
||||
error_message,
|
||||
} => {
|
||||
(request_id, Err(ClientError::InternalProtocol(ProtocolError::Other(error_message))))
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(response_result_tx) = response_senders.remove(&request_id) {
|
||||
// The channel might be closed if the task that called
|
||||
// `send_message` has been dropped, but that's ok.
|
||||
let _ = response_result_tx.send(response_result);
|
||||
} else {
|
||||
// When there is no corresponding response_senders
|
||||
// entry for the message's request ID, we weren't
|
||||
// expecting it.
|
||||
log::warn!("Received unexpected message with id {request_id}.");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
match e {
|
||||
ProtocolError::Disconnected(_)=> {
|
||||
// The server was disconnected, so break and exit.
|
||||
break;
|
||||
}
|
||||
e => {
|
||||
log::warn!("Error occurred while receiving message: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
complete => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls `outbound_message_rx` for request messages and sends them over the IPC transport.
|
||||
///
|
||||
/// If a request is sent successfully, the `response_result_tx` from the corresponding
|
||||
/// `OutboundRequest` is sent to the _inbound_ response task, which sends the response through
|
||||
/// it once received.
|
||||
async fn handle_outgoing_requests(
|
||||
mut writer: impl AsyncWrite + Unpin,
|
||||
outbound_request_rx: async_channel::Receiver<OutboundRequest>,
|
||||
pending_request_info_tx: async_channel::Sender<PendingRequestInfo>,
|
||||
) {
|
||||
while let Ok(OutboundRequest {
|
||||
request,
|
||||
response_result_tx,
|
||||
}) = outbound_request_rx.recv().await
|
||||
{
|
||||
let request_id = *request.id();
|
||||
match send_message(&mut writer, request).await {
|
||||
Ok(()) => {
|
||||
if pending_request_info_tx.is_closed() {
|
||||
// The channel might be closed if the task that called
|
||||
// `send_message` has been dropped, but that's ok.
|
||||
let _ = response_result_tx
|
||||
.send(Err(ClientError::PendingRequestInfoChannelClosed));
|
||||
} else {
|
||||
// Let the inbound traffic task know that we successfully sent a
|
||||
// request, so it can relay the response back to the caller.
|
||||
//
|
||||
// We pass on the `response_result_tx` from the `OutboundRequest`
|
||||
// object, which will eventually be used to relay the response.
|
||||
let pending_request_info = PendingRequestInfo {
|
||||
request_id,
|
||||
response_result_tx,
|
||||
};
|
||||
let _ = pending_request_info_tx.send(pending_request_info).await;
|
||||
}
|
||||
}
|
||||
Err(ProtocolError::Disconnected(_)) => {
|
||||
// The channel might be closed if the task that called
|
||||
// `send_message` has been dropped, but that's ok.
|
||||
let _ = response_result_tx.send(Err(ClientError::Disconnected));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
// The channel might be closed if the task that called
|
||||
// `send_message` has been dropped, but that's ok.
|
||||
let _ = response_result_tx.send(Err(ClientError::InternalProtocol(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! This crate provides an ipmlementation of a basic IPC request/response protocol.
|
||||
//!
|
||||
//! Users may instantiate a server that implements any number of [`Service`]s as well as
|
||||
//! corresponding typed "clients" ([`ServiceCaller`]s) which provide a typed interface to call the
|
||||
//! services across process boundaries.
|
||||
//!
|
||||
//! This is intended to initially be used to support communication between the Warp app and
|
||||
//! third-party plugins running in a separate "plugin host" process, but is designed generically to
|
||||
//! be extended to other use cases (such as the terminal server). Where possible,
|
||||
//! transport-specific details are abstracted out to eventually support the same protocol on top of
|
||||
//! the WebWorkers `MessagePort` API in the browser for Warp on Web.
|
||||
//!
|
||||
//! On native platforms, this is implemented on top of the `interprocess` crate, which uses
|
||||
//! Unix Domain Sockets on Unix platforms and named pipes on Windows as the underlying transport.
|
||||
//!
|
||||
//! WASM (wasm32-unknown-unknown) is currently unsupported.
|
||||
//!
|
||||
//!
|
||||
//! Basic usage is like so:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // In the server's process...
|
||||
//! let background_executor = ctx.background_executor();
|
||||
//!
|
||||
//! // `MyServiceImpl` implements `ServiceImpl<Service = MyService>`.
|
||||
//! let my_service_impl = MyServiceImpl::new();
|
||||
//! let (server, connection_address) = ServerBuilder::default()
|
||||
//! .with_service(my_service_impl)
|
||||
//! .build_and_run(background_executor)
|
||||
//! .expect("Failed to instantiate server");
|
||||
//!
|
||||
//! // In the client process, passing the same connection address returned from the server
|
||||
//! // instantiation (possibly as an environment variable set in the client process).
|
||||
//! let client = Arc::new(
|
||||
//! Client::connect(connection_address, background_executor)
|
||||
//! .await
|
||||
//! .expect("Failed to connect client"),
|
||||
//! );
|
||||
//! let my_service_stub = service_caller::<MyService>(client);
|
||||
//! let response = my_service_stub.call(MyServiceRequest { .. }).await;
|
||||
//! ```
|
||||
mod client;
|
||||
mod protocol;
|
||||
mod server;
|
||||
mod service;
|
||||
|
||||
// Platform-specific implementations of the underlying transport for both server and client. For
|
||||
// native platforms, this uses the `interprocess` crate. On wasm, we plan to use the WebWorkers
|
||||
// MessagePort API, but this is not yet implemented.
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "native.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "wasm.rs")]
|
||||
mod platform;
|
||||
|
||||
pub use client::{Client, ClientError};
|
||||
pub use protocol::ConnectionAddress;
|
||||
pub use server::{Server, ServerBuilder};
|
||||
pub use service::{service_caller, Service, ServiceCaller, ServiceImpl};
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod testing;
|
||||
@@ -0,0 +1,63 @@
|
||||
//! This module implements IPC transport on top of the `interprocess` crate, which uses Unix Domain
|
||||
//! Sockets on Unix platforms and named pipes on Windows under the hood.
|
||||
use async_compat::CompatExt as _;
|
||||
use futures::{AsyncRead, AsyncWrite};
|
||||
|
||||
use crate::ConnectionAddress;
|
||||
|
||||
pub(crate) mod client {
|
||||
use super::*;
|
||||
use crate::client::{ClientError, InitializationError, Result};
|
||||
use interprocess::local_socket::tokio::LocalSocketStream;
|
||||
|
||||
/// Returns a tuple containing structs for reading and writing to a local socket, which is the
|
||||
/// underlying IPC transport for native (non-wasm) platforms.
|
||||
pub async fn connect_client(
|
||||
connection_address: ConnectionAddress,
|
||||
) -> Result<(impl AsyncRead + Unpin, impl AsyncWrite + Unpin)> {
|
||||
let stream = LocalSocketStream::connect(connection_address.0.as_str())
|
||||
.compat()
|
||||
.await
|
||||
.map_err(|e| ClientError::Initialization(InitializationError::Io(e)))?;
|
||||
Ok(stream.into_split())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod server {
|
||||
use super::*;
|
||||
use crate::server::{InitializationError, Result, ServerError};
|
||||
use interprocess::local_socket::tokio::{LocalSocketListener, LocalSocketStream};
|
||||
|
||||
pub struct ConnectionImpl {
|
||||
stream: LocalSocketStream,
|
||||
}
|
||||
|
||||
impl ConnectionImpl {
|
||||
pub fn into_split(self) -> (impl AsyncRead + Unpin, impl AsyncWrite + Unpin) {
|
||||
self.stream.into_split()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConnectionListenerImpl {
|
||||
listener: LocalSocketListener,
|
||||
}
|
||||
|
||||
impl ConnectionListenerImpl {
|
||||
pub fn new(connection_address: ConnectionAddress) -> Result<Self> {
|
||||
let listener = warpui::r#async::block_on(
|
||||
async move { LocalSocketListener::bind(connection_address.to_string()) }.compat(),
|
||||
)
|
||||
.map_err(|e| ServerError::Initialization(InitializationError::Io(e)))?;
|
||||
Ok(Self { listener })
|
||||
}
|
||||
|
||||
pub async fn accept_connection(&self) -> Result<ConnectionImpl> {
|
||||
self.listener
|
||||
.accept()
|
||||
.compat()
|
||||
.await
|
||||
.map(|stream| ConnectionImpl { stream })
|
||||
.map_err(ServerError::AcceptConnection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::{
|
||||
fmt::{Debug, Display},
|
||||
marker::Unpin,
|
||||
};
|
||||
|
||||
use futures::{
|
||||
io::{AsyncReadExt, AsyncWriteExt, BufReader},
|
||||
AsyncRead, AsyncWrite,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::service::ServiceId;
|
||||
|
||||
/// The size of a usize, in bytes.
|
||||
const USIZE_SIZE: usize = std::mem::size_of::<usize>();
|
||||
|
||||
/// Unique "address" for a server/client connection.
|
||||
///
|
||||
/// In the case of this local socket implementation, this is a socket address (path on the
|
||||
/// filesystem). Conceptually, this somewhat similar to an IP address + port.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
pub struct ConnectionAddress(pub(super) String);
|
||||
|
||||
impl ConnectionAddress {
|
||||
/// Returns a `ConnectionAddress` containing a path for a socket address.
|
||||
pub(super) fn new() -> Self {
|
||||
Self(format!("/tmp/warp-ipc-{}.sock", rand::random::<i64>()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ConnectionAddress {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ConnectionAddress {
|
||||
fn from(value: String) -> Self {
|
||||
ConnectionAddress(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A unique ID for each request message.
|
||||
///
|
||||
/// The corresponding response for the request should contain the same ID.
|
||||
pub(super) type RequestId = Uuid;
|
||||
|
||||
/// Trait for arbitrary messages that may be sent across the 'wire' (the socket).
|
||||
pub trait Message: 'static + Send + Sync + Debug + Clone + DeserializeOwned + Serialize {}
|
||||
impl<T> Message for T where T: 'static + Send + Sync + Debug + Clone + DeserializeOwned + Serialize {}
|
||||
|
||||
/// Request message sent by clients and received by servers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub(super) struct Request {
|
||||
/// A unique ID for the request.
|
||||
pub(super) id: RequestId,
|
||||
|
||||
/// The ID of the service to which this request belongs.
|
||||
pub(super) service_id: ServiceId,
|
||||
|
||||
/// The actual request payload.
|
||||
pub(super) bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Constructs a `Request`, generating a unique request ID in the process.
|
||||
pub(super) fn new(service_id: ServiceId, bytes: Vec<u8>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
service_id,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn id(&self) -> &RequestId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Response message sent by servers and received by clients.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub(super) enum Response {
|
||||
/// For responses produced "successfully". "Successful" only pertains to the frameworks ability
|
||||
/// to successfully execute the `Service` handler and produce a response. `Service`s may
|
||||
/// internally implement their own error types/response schemas.
|
||||
Success {
|
||||
/// The ID of the request for which this is a response.
|
||||
request_id: RequestId,
|
||||
|
||||
/// The ID of the service to which this response belongs.
|
||||
service_id: ServiceId,
|
||||
|
||||
/// The actual response payload.
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
|
||||
/// For responses that failed due to a framework-level issue. For example, the client attempted
|
||||
/// to call a service that wasn't registered in the server.
|
||||
Failure {
|
||||
/// The ID of the request for which this is a response.
|
||||
request_id: RequestId,
|
||||
|
||||
error_message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Constructs a "success" response for the request with the given `request_id`.
|
||||
pub(super) fn success(request_id: RequestId, service_id: ServiceId, bytes: Vec<u8>) -> Self {
|
||||
Self::Success {
|
||||
request_id,
|
||||
service_id,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a "failure" response for the request with the given `request_id`.
|
||||
pub(super) fn failure(request_id: RequestId, error_message: String) -> Self {
|
||||
Self::Failure {
|
||||
request_id,
|
||||
error_message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ProtocolError {
|
||||
/// An error occurred when serializing the request or response.
|
||||
#[error(transparent)]
|
||||
Serialization(#[from] bincode::Error),
|
||||
|
||||
/// The connection was dropped.
|
||||
#[error(transparent)]
|
||||
Disconnected(#[from] std::io::Error),
|
||||
|
||||
#[error("Unknown error occurred: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Writes the given message to the given `writer`.
|
||||
pub(super) async fn send_message<M, W>(writer: &mut W, message: M) -> Result<(), ProtocolError>
|
||||
where
|
||||
M: Message,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let serialized_msg = bincode::serialize(&message)?;
|
||||
|
||||
// Create a buffer to hold the data to be written.
|
||||
let mut buf = Vec::with_capacity(serialized_msg.len() + USIZE_SIZE);
|
||||
|
||||
// First, add a message "header" - a usize representing the length of the
|
||||
// serialized payload, in bytes.
|
||||
buf.extend_from_slice(&serialized_msg.len().to_be_bytes());
|
||||
|
||||
// Next, add the serialized payload itself.
|
||||
buf.extend(serialized_msg);
|
||||
|
||||
// Finally, write the buffer to the underlying transport.
|
||||
Ok(writer.write_all(&buf[..]).await?)
|
||||
}
|
||||
|
||||
/// Reads the next message from the given `reader`.
|
||||
pub(super) async fn receive_message<M, R>(reader: &mut BufReader<R>) -> Result<M, ProtocolError>
|
||||
where
|
||||
M: Message,
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
// Start by allocating a buffer that is only large enough to receive the
|
||||
// message header, to ensure we don't accidentally receive multiple messages
|
||||
// in a single read.
|
||||
let mut header_buf = [0; USIZE_SIZE];
|
||||
|
||||
// Read the message "header" from the socket.
|
||||
reader.read_exact(&mut header_buf[..]).await?;
|
||||
|
||||
// Parse the message header - we convert the bytes back into a usize, which
|
||||
// tells us the size of the serialized message, in bytes. We add the size
|
||||
// of a usize to get the total number of bytes we expect to read off the
|
||||
// wire.
|
||||
let payload_len = usize::from_be_bytes(header_buf);
|
||||
|
||||
// Grow the initial buffer to a sufficient size and read the rest of the
|
||||
// message from the socket.
|
||||
let mut payload_buf = vec![0; payload_len];
|
||||
reader.read_exact(&mut payload_buf).await?;
|
||||
|
||||
// Deserialize the message.
|
||||
let message: M = bincode::deserialize(&payload_buf[..])?;
|
||||
Ok(message)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use async_channel::{Receiver, Sender};
|
||||
use async_trait::async_trait;
|
||||
use futures::{io::BufReader, AsyncRead, AsyncWrite};
|
||||
use warpui::r#async::executor::{Background, BackgroundTask};
|
||||
|
||||
use crate::{
|
||||
platform::server::{ConnectionImpl, ConnectionListenerImpl},
|
||||
service::ServiceImpl,
|
||||
};
|
||||
|
||||
use super::{
|
||||
protocol::{
|
||||
receive_message, send_message, ConnectionAddress, ProtocolError, Request, Response,
|
||||
},
|
||||
service::{service_id, Service, ServiceId},
|
||||
};
|
||||
|
||||
/// Helper trait to enable storing a polymorphic collection of `ServiceImpl` implementions in
|
||||
/// `Server`.
|
||||
///
|
||||
/// This is akin to the `AnyView` and `AnyModel` traits used by the UI framework to similarly store
|
||||
/// `View` callbacks that are actually parameterized by the type of the actual `View`
|
||||
/// implementation.
|
||||
#[async_trait]
|
||||
pub(super) trait AnyServiceImpl: Send + Sync {
|
||||
async fn handle_request(&self, request: &[u8]) -> Vec<u8>;
|
||||
|
||||
fn clone_service(&self) -> Box<dyn AnyServiceImpl>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<I, S> AnyServiceImpl for I
|
||||
where
|
||||
S: Service,
|
||||
I: ServiceImpl<Service = S> + Clone + Sized,
|
||||
{
|
||||
async fn handle_request(&self, request_bytes: &[u8]) -> Vec<u8> {
|
||||
let request: S::Request =
|
||||
bincode::deserialize(request_bytes).expect("Failed to deserialize request bytes.");
|
||||
bincode::serialize::<S::Response>(&I::handle_request(self, request).await)
|
||||
.expect("Should be able to serialize response.")
|
||||
}
|
||||
|
||||
fn clone_service(&self) -> Box<dyn AnyServiceImpl> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn AnyServiceImpl> {
|
||||
fn clone(&self) -> Self {
|
||||
self.clone_service()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InitializationError {
|
||||
Io(std::io::Error),
|
||||
UnsupportedPlatform,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ServerError {
|
||||
#[error("Failed to initialize server: {0:?}")]
|
||||
Initialization(InitializationError),
|
||||
|
||||
#[error("Failed to accept connection: {0:?}")]
|
||||
AcceptConnection(std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ServerError>;
|
||||
|
||||
/// A wrapper struct for abstracting-away platform-specific implementations for the server
|
||||
/// functionality that listens for and accepts new connections.
|
||||
struct ConnectionListener(ConnectionListenerImpl);
|
||||
|
||||
impl ConnectionListener {
|
||||
fn new(connection_address: ConnectionAddress) -> Result<Self> {
|
||||
ConnectionListenerImpl::new(connection_address).map(Self)
|
||||
}
|
||||
|
||||
/// Waits until a client connects and returns the connection.
|
||||
async fn accept_connection(&self) -> Result<Connection> {
|
||||
self.0.accept_connection().await.map(Connection)
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper struct for abstracting-away platform-specific implementations of the underlying
|
||||
/// transport for the IPC connection.
|
||||
///
|
||||
/// The main property of a [`Connection`] is that it can be consumed to create read and write
|
||||
/// 'halves' which can be used to asynchronously read/write bytes to/from the transport.
|
||||
struct Connection(ConnectionImpl);
|
||||
|
||||
impl Connection {
|
||||
/// Returns an `AsyncRead` impl to read bytes from the transport and `AsyncWrite` to write
|
||||
/// bytes to the transport.
|
||||
fn into_split(self) -> (impl AsyncRead + Unpin, impl AsyncWrite + Unpin) {
|
||||
self.0.into_split()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct for building and running a server.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let (server, connection_address) = ServerBuilder::default()
|
||||
/// // Implements `ServiceImpl<MyService>`.
|
||||
/// .with_service(MyServiceImpl::new())
|
||||
/// .build_and_run()
|
||||
/// .expect("Failed to run server.");
|
||||
/// ```
|
||||
#[derive(Default)]
|
||||
pub struct ServerBuilder {
|
||||
services: HashMap<ServiceId, Box<dyn AnyServiceImpl>>,
|
||||
fixed_connection_address: Option<ConnectionAddress>,
|
||||
}
|
||||
|
||||
impl ServerBuilder {
|
||||
pub fn with_service<S: ServiceImpl + Sized>(mut self, service_impl: S) -> Self {
|
||||
self.services
|
||||
.insert(service_id::<S::Service>(), Box::new(service_impl));
|
||||
self
|
||||
}
|
||||
|
||||
/// Use a fixed address name instead of a randomly generated one.
|
||||
pub fn with_fixed_address(mut self, fixed_address: String) -> Self {
|
||||
self.fixed_connection_address = Some(ConnectionAddress::from(fixed_address));
|
||||
self
|
||||
}
|
||||
|
||||
/// Instantiates a `Server` which listens for incoming client connections.
|
||||
///
|
||||
/// If the server instantiation fails, returns an error.
|
||||
pub fn build_and_run(
|
||||
self,
|
||||
background_executor: Arc<Background>,
|
||||
) -> Result<(Server, ConnectionAddress)> {
|
||||
let connection_address =
|
||||
if let Some(fixed_connection_address) = self.fixed_connection_address {
|
||||
fixed_connection_address
|
||||
} else {
|
||||
ConnectionAddress::new()
|
||||
};
|
||||
Server::run(
|
||||
connection_address.clone(),
|
||||
self.services,
|
||||
background_executor,
|
||||
)
|
||||
.map(|server| (server, connection_address))
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves registered `Service` implementations over platform-specific IPC transport.
|
||||
///
|
||||
/// Two background tasks are spawned for each client connection -- one for processing incoming
|
||||
/// requests and one for sending outgoing responses.
|
||||
pub struct Server {
|
||||
_tasks: Vec<BackgroundTask>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Runs the main server tasks.
|
||||
///
|
||||
/// Two main tasks are spawned immediately -- one for listening for incoming client connections
|
||||
/// and one for "accepting" connections that were found. When "accepting" a connection, two
|
||||
/// additional connection-specific tasks are spawned -- one for processing incoming requests
|
||||
/// and one for sending outbound responses.
|
||||
fn run(
|
||||
connection_address: ConnectionAddress,
|
||||
services: HashMap<ServiceId, Box<dyn AnyServiceImpl>>,
|
||||
background_executor: Arc<Background>,
|
||||
) -> Result<Self> {
|
||||
let listener = ConnectionListener::new(connection_address)?;
|
||||
|
||||
// Spawn two separate background tasks. The first is responsible for listening for new
|
||||
// client connections and passing them to the second, which itself spawns tasks to process
|
||||
// inbound requests and outbound responses from each connection.
|
||||
//
|
||||
// A channel is used to pass connections between the two tasks.
|
||||
let (new_connection_tx, new_connection_rx) = async_channel::unbounded();
|
||||
let tasks = vec![
|
||||
background_executor.spawn(Self::listen_for_new_connections(
|
||||
listener,
|
||||
new_connection_tx,
|
||||
)),
|
||||
background_executor.spawn(Self::accept_new_connections(
|
||||
services,
|
||||
new_connection_rx,
|
||||
background_executor.clone(),
|
||||
)),
|
||||
];
|
||||
Ok(Self { _tasks: tasks })
|
||||
}
|
||||
|
||||
/// Listens for new connections on `listener`, relaying them through the given sender.
|
||||
async fn listen_for_new_connections(
|
||||
listener: ConnectionListener,
|
||||
new_connection_tx: Sender<Connection>,
|
||||
) {
|
||||
loop {
|
||||
match listener.accept_connection().await {
|
||||
Ok(stream) => {
|
||||
if new_connection_tx.send(stream).await.is_err() {
|
||||
// The task responsible for handling new connections has
|
||||
// exited, so break and exit too.
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not establish connection with client: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receives new connections from the given `Receiver` and spawns dedicated background tasks
|
||||
/// for processing incoming request messages and outgoing response messages.
|
||||
async fn accept_new_connections(
|
||||
services: HashMap<ServiceId, Box<dyn AnyServiceImpl>>,
|
||||
new_connection_rx: Receiver<Connection>,
|
||||
background_executor: Arc<Background>,
|
||||
) {
|
||||
// Maintain references to the task handles so they're cancelled when this is dropped.
|
||||
let mut tasks = vec![];
|
||||
|
||||
loop {
|
||||
let Ok(connection) = new_connection_rx.recv().await else {
|
||||
// The task responsible for listening for new connections has exited, so
|
||||
// break and exit too.
|
||||
return;
|
||||
};
|
||||
|
||||
let (reader, writer) = connection.into_split();
|
||||
let (response_tx, response_rx) = async_channel::unbounded::<Response>();
|
||||
|
||||
tasks.push(background_executor.spawn(Self::handle_incoming_requests(
|
||||
reader,
|
||||
services.clone(),
|
||||
response_tx,
|
||||
)));
|
||||
tasks.push(
|
||||
background_executor.spawn(Self::handle_outgoing_responses(writer, response_rx)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes incoming request messages.
|
||||
///
|
||||
/// This includes deserializing the request message into a `Service`-specific request type,
|
||||
/// dispatching the request to the `Service` itself, and sending the resulting response thru
|
||||
/// the given `response_tx`.
|
||||
///
|
||||
/// The receiving end of the `response_tx` channel is processed in a separate task dedicated to
|
||||
/// sending outbound messages back to the client.
|
||||
async fn handle_incoming_requests(
|
||||
reader: impl AsyncRead + Unpin,
|
||||
services: HashMap<ServiceId, Box<dyn AnyServiceImpl>>,
|
||||
response_tx: Sender<Response>,
|
||||
) {
|
||||
let mut reader = BufReader::new(reader);
|
||||
loop {
|
||||
match receive_message(&mut reader).await {
|
||||
Ok(Request {
|
||||
id,
|
||||
service_id,
|
||||
bytes,
|
||||
}) => {
|
||||
let response_message = match services.get(&service_id) {
|
||||
Some(service) => {
|
||||
let response_bytes = service.handle_request(&bytes[..]).await;
|
||||
Response::success(id, service_id, response_bytes)
|
||||
}
|
||||
None => {
|
||||
Response::failure(id, format!("No such service (ID: {service_id})"))
|
||||
}
|
||||
};
|
||||
|
||||
if response_tx.send(response_message).await.is_err() {
|
||||
// This means the response_tx channel is closed, which probably
|
||||
// means the outgoing messages task has exited. So this task should
|
||||
// exit too.
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
match e {
|
||||
ProtocolError::Serialization(e) => {
|
||||
log::warn!("Failed to deserialize request: {e:?}");
|
||||
}
|
||||
ProtocolError::Disconnected(_) => {
|
||||
// The socket is disconnected, so exit.
|
||||
log::warn!("IPC server disconnected unexpectedly.");
|
||||
break;
|
||||
}
|
||||
e => {
|
||||
log::warn!("Unknown error occurred when receiving request: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process outgoing response messages, received from the given `response_rx` receiver.
|
||||
async fn handle_outgoing_responses(
|
||||
mut writer: impl AsyncWrite + Unpin,
|
||||
response_rx: Receiver<Response>,
|
||||
) {
|
||||
while let Ok(message) = response_rx.recv().await {
|
||||
if let Err(e) = send_message(&mut writer, message).await {
|
||||
match e {
|
||||
ProtocolError::Serialization(e) => {
|
||||
log::warn!("Failed to serialize response: {e:?}");
|
||||
}
|
||||
ProtocolError::Disconnected(_) => {
|
||||
// The socket is disconnected, so exit.
|
||||
break;
|
||||
}
|
||||
e => {
|
||||
log::warn!("Unknown error occurred when sending response: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use std::{marker::PhantomData, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{protocol::Message, Client, ClientError};
|
||||
|
||||
pub(crate) type ServiceId = String;
|
||||
|
||||
/// Returns a unique ID for the `Service` implementation specified as a type parameter.
|
||||
pub(crate) fn service_id<S: Service>() -> ServiceId {
|
||||
std::any::type_name::<S>().to_owned()
|
||||
}
|
||||
|
||||
/// A typed IPC service interface.
|
||||
///
|
||||
/// Implementations should implement `ServiceImpl` and be registered on the server, while clients
|
||||
/// can use `ServiceCaller` to call the service.
|
||||
#[async_trait]
|
||||
pub trait Service: Send + Sync + 'static {
|
||||
type Request: Message + 'static;
|
||||
type Response: Message + 'static;
|
||||
}
|
||||
|
||||
/// To be implemented for each IPC service, where a service has a defined request/response type.
|
||||
///
|
||||
/// This should be implemented and is registered on the "Server" side.
|
||||
///
|
||||
/// Though it is technically up to users to determine whether to use a collection of `Service`s
|
||||
/// or implement a single service that delegates internally, prefer the former.
|
||||
#[async_trait]
|
||||
pub trait ServiceImpl: 'static + Send + Sync + Clone {
|
||||
type Service: Service;
|
||||
|
||||
async fn handle_request(
|
||||
&self,
|
||||
request: <<Self as ServiceImpl>::Service as Service>::Request,
|
||||
) -> <<Self as ServiceImpl>::Service as Service>::Response;
|
||||
}
|
||||
|
||||
/// Provides an typed interface to call an underlying `Service`.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let client = Arc::new(
|
||||
/// Client::connect(connection_address, executor)
|
||||
/// .await
|
||||
/// .expect("Failed to connect client."),
|
||||
/// );
|
||||
/// let foo = ServiceCaller::<FooService>::new(client);
|
||||
/// let response = foo.call(FooRequest {}).await;
|
||||
/// ```
|
||||
#[async_trait]
|
||||
pub trait ServiceCaller<S: Service>: Send + Sync {
|
||||
async fn call(&self, request: S::Request) -> Result<S::Response, ClientError>;
|
||||
}
|
||||
|
||||
/// Returns a `ServiceCaller` implementation for the service `S`.
|
||||
pub fn service_caller<S: Service>(client: Arc<Client>) -> Box<dyn ServiceCaller<S>> {
|
||||
Box::new(RealServiceCaller::new(client))
|
||||
}
|
||||
|
||||
/// Real `ServiceCaller` implementation.
|
||||
struct RealServiceCaller<S> {
|
||||
client: Arc<Client>,
|
||||
_service_type_marker: PhantomData<S>,
|
||||
}
|
||||
|
||||
impl<S> RealServiceCaller<S>
|
||||
where
|
||||
S: Service,
|
||||
{
|
||||
fn new(client: Arc<Client>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
_service_type_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> ServiceCaller<S> for RealServiceCaller<S>
|
||||
where
|
||||
S: Service,
|
||||
{
|
||||
/// Sends the given request and returns a `Result` containing its response.
|
||||
async fn call(&self, request: S::Request) -> Result<S::Response, ClientError> {
|
||||
let request_bytes = bincode::serialize(&request).expect("Failed to serialize request.");
|
||||
self.client
|
||||
.send_request::<S>(request_bytes)
|
||||
.await
|
||||
.map(|response_bytes| {
|
||||
bincode::deserialize::<S::Response>(&response_bytes[..])
|
||||
.expect("Failed to deserialize response.")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Mock implementation of `ServiceCaller` for use in tests.
|
||||
//!
|
||||
//! This requires service request to implement `Hash`, `PartialEq`, `Eq` to make assertions about
|
||||
//! expected requests.
|
||||
//!
|
||||
//! If any unexpected request is called, then the caller panics. Additionally, if any expected
|
||||
//! requests were _not_ actually called, the caller panics upon being dropped.
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! ```
|
||||
//! let mock_caller = MockServiceCaller::<MyService>::new();
|
||||
//! mock_caller.expect_response(MyRequest { foo: "bar" }, MyResponse { bar: "baz" });
|
||||
//! ```
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use itertools::Itertools;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::{service::service_id, ClientError, Service, ServiceCaller};
|
||||
|
||||
// Use a `Mutex` so we can satisfy the immutable `&self` in the implementation of `ServiceCaller`.
|
||||
type ExpectationsMap<T, U> = Mutex<HashMap<T, Result<U, ClientError>>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MockServiceCaller<S>
|
||||
where
|
||||
S: Service,
|
||||
<S as Service>::Request: Hash + PartialEq + Eq,
|
||||
<S as Service>::Response: Hash + PartialEq + Eq,
|
||||
{
|
||||
expectations: ExpectationsMap<S::Request, S::Response>,
|
||||
}
|
||||
|
||||
impl<S> MockServiceCaller<S>
|
||||
where
|
||||
S: Service,
|
||||
<S as Service>::Request: Hash + PartialEq + Eq,
|
||||
<S as Service>::Response: Hash + PartialEq + Eq,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
expectations: Mutex::new(HashMap::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expect_response(
|
||||
&mut self,
|
||||
expected_request: S::Request,
|
||||
response: Result<S::Response, ClientError>,
|
||||
) {
|
||||
self.expectations.lock().insert(expected_request, response);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> ServiceCaller<S> for MockServiceCaller<S>
|
||||
where
|
||||
S: Service,
|
||||
<S as Service>::Request: Hash + PartialEq + Eq,
|
||||
<S as Service>::Response: Hash + PartialEq + Eq,
|
||||
{
|
||||
async fn call(&self, request: S::Request) -> Result<S::Response, ClientError> {
|
||||
let response = self.expectations.lock().remove(&request);
|
||||
match response {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
panic!("Unexpected IPC call with request: {:?}", &request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Drop for MockServiceCaller<S>
|
||||
where
|
||||
S: Service,
|
||||
<S as Service>::Request: Hash + PartialEq + Eq,
|
||||
<S as Service>::Response: Hash + PartialEq + Eq,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
if !self.expectations.lock().is_empty() {
|
||||
panic!(
|
||||
"ServiceCaller for {} has unmet expectations: {:?}",
|
||||
service_id::<S>(),
|
||||
self.expectations.lock().drain().collect_vec()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! This module provides a fake, "placeholder" implementation of IPC transport for wasm targets.
|
||||
//!
|
||||
//! Eventually, this module will implement transport on top of the WebWorkers MessagePort API.
|
||||
use futures::{AsyncRead, AsyncWrite};
|
||||
|
||||
use crate::ConnectionAddress;
|
||||
|
||||
pub(crate) mod client {
|
||||
use crate::client::{ClientError, InitializationError, Result};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub async fn connect_client(
|
||||
_connection_address: ConnectionAddress,
|
||||
) -> Result<(futures::io::Empty, futures::io::Sink)> {
|
||||
Err(ClientError::Initialization(
|
||||
InitializationError::UnsupportedPlatform,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod server {
|
||||
use super::*;
|
||||
use crate::server::{InitializationError, Result, ServerError};
|
||||
|
||||
/// "Fake" implementation. Note that because a `ConnectionListenerImpl` can't be instantiated,
|
||||
/// a `ConnectionImpl` cannot actually be instantiated either.
|
||||
pub struct ConnectionImpl {
|
||||
/// A dummy placeholder field to prevent instantiation of a Connection because this crate
|
||||
/// currently doesn't support wasm.
|
||||
_marker: bool,
|
||||
}
|
||||
|
||||
impl ConnectionImpl {
|
||||
pub fn into_split(self) -> (impl AsyncRead + Unpin, impl AsyncWrite + Unpin) {
|
||||
(futures::io::empty(), futures::io::sink())
|
||||
}
|
||||
}
|
||||
|
||||
/// "Fake" implementation that cannot actually be initialized.
|
||||
pub struct ConnectionListenerImpl {
|
||||
/// A dummy placeholder field to prevent instantiation of a ConnectionListener because this crate
|
||||
/// currently doesn't support wasm.
|
||||
_marker: bool,
|
||||
}
|
||||
|
||||
impl ConnectionListenerImpl {
|
||||
/// Returns an unsupported platform error, since this crate currently doesn't support wasm.
|
||||
pub fn new(_connection_address: ConnectionAddress) -> Result<Self> {
|
||||
Err(ServerError::Initialization(
|
||||
InitializationError::UnsupportedPlatform,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn accept_connection(&self) -> Result<ConnectionImpl> {
|
||||
// This can never be called because its impossible to instantiate a ConnectionListener (on
|
||||
// wasm).
|
||||
unreachable!("ConnectionListener cannot be instantiated when targeting wasm.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user