Initial commit
commit
753a0be931
|
@ -0,0 +1,3 @@
|
||||||
|
/target/
|
||||||
|
/config.toml
|
||||||
|
/mqtt.password
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "mqtt2vl"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.40", default-features = false, features = ["std", "now", "serde"] }
|
||||||
|
futures = "0.3.31"
|
||||||
|
paho-mqtt = { version = "0.13.2", default-features = false, features = ["ssl"] }
|
||||||
|
reqwest = { version = "0.12.15", default-features = false, features = ["http2", "stream", "native-tls", "json"] }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
serde_json = "1.0.140"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
tokio = { version = "1.44.2", features = ["rt", "macros", "rt-multi-thread", "signal"] }
|
||||||
|
tokio-stream = { version = "0.1.17", default-features = false, features = ["sync"] }
|
||||||
|
toml = "0.8.22"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
|
@ -0,0 +1 @@
|
||||||
|
max_width = 79
|
|
@ -0,0 +1,47 @@
|
||||||
|
use std::iter::Iterator;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
pub struct Backoff {
|
||||||
|
duration: Duration,
|
||||||
|
max: Duration,
|
||||||
|
min: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Backoff {
|
||||||
|
fn default() -> Self {
|
||||||
|
let min = Duration::from_millis(250);
|
||||||
|
let max = Duration::from_secs(300);
|
||||||
|
let duration = min;
|
||||||
|
Self { duration, max, min }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for Backoff {
|
||||||
|
type Item = Duration;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
let duration = self.duration;
|
||||||
|
self.duration = std::cmp::min(self.duration * 2, self.max);
|
||||||
|
Some(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backoff {
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.duration = self.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sleep(&mut self) {
|
||||||
|
warn!(
|
||||||
|
"Retrying in {}",
|
||||||
|
if self.duration.as_secs() < 1 {
|
||||||
|
format!("{} ms", self.duration.as_millis())
|
||||||
|
} else {
|
||||||
|
format!("{} seconds", self.duration.as_secs())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tokio::time::sleep(self.next().unwrap()).await;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct HttpConfiguration {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MqttConfiguration {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default = "default_mqtt_client_id")]
|
||||||
|
pub client_id: String,
|
||||||
|
pub ca_file: Option<PathBuf>,
|
||||||
|
pub client_cert: Option<PathBuf>,
|
||||||
|
pub client_key: Option<PathBuf>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub password_file: Option<PathBuf>,
|
||||||
|
pub topics: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Configuration {
|
||||||
|
pub mqtt: MqttConfiguration,
|
||||||
|
pub http: HttpConfiguration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Configuration {
|
||||||
|
fn default() -> Self {
|
||||||
|
Configuration {
|
||||||
|
mqtt: MqttConfiguration {
|
||||||
|
url: "mqtt://127.0.0.1:1883".into(),
|
||||||
|
topics: vec!["/debug".into()],
|
||||||
|
client_id: default_mqtt_client_id(),
|
||||||
|
ca_file: None,
|
||||||
|
client_cert: None,
|
||||||
|
client_key: None,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
password_file: None,
|
||||||
|
},
|
||||||
|
http: HttpConfiguration {
|
||||||
|
url: "http://127.0.0.1:9428/insert/jsonline?_stream_fields=topic"
|
||||||
|
.into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Configuration {
|
||||||
|
pub fn from_file<P: AsRef<Path>>(
|
||||||
|
path: P,
|
||||||
|
) -> Result<Self, ConfigurationError> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
Ok(toml::from_str(&content)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mqtt_client_id() -> String {
|
||||||
|
env!("CARGO_PKG_NAME").into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigurationError {
|
||||||
|
#[error("Error reading file: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Error parsing TOML: {0}")]
|
||||||
|
Toml(#[from] toml::de::Error),
|
||||||
|
}
|
|
@ -0,0 +1,228 @@
|
||||||
|
mod backoff;
|
||||||
|
mod config;
|
||||||
|
mod mqtt;
|
||||||
|
mod relay;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::{DateTime, FixedOffset, Utc};
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::signal::unix::SignalKind;
|
||||||
|
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use backoff::Backoff;
|
||||||
|
use config::Configuration;
|
||||||
|
use mqtt::MqttClient;
|
||||||
|
|
||||||
|
static USER_AGENT: &str =
|
||||||
|
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct LogRecord {
|
||||||
|
#[serde(rename = "_time")]
|
||||||
|
time: DateTime<FixedOffset>,
|
||||||
|
#[serde(rename = "_msg")]
|
||||||
|
message: String,
|
||||||
|
topic: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<paho_mqtt::Message> for LogRecord {
|
||||||
|
type Error = std::str::Utf8Error;
|
||||||
|
|
||||||
|
fn try_from(m: paho_mqtt::Message) -> Result<Self, Self::Error> {
|
||||||
|
let message = std::str::from_utf8(m.payload())?.to_string();
|
||||||
|
Ok(Self {
|
||||||
|
time: Utc::now().into(),
|
||||||
|
message,
|
||||||
|
topic: m.topic().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_sender(
|
||||||
|
client: reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
chan: UnboundedReceiver<LogRecord>,
|
||||||
|
notify: &Notify,
|
||||||
|
) {
|
||||||
|
let mut backoff = Backoff::default();
|
||||||
|
let relay = relay::Relay::from(chan);
|
||||||
|
loop {
|
||||||
|
if relay.closed().await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let req = client
|
||||||
|
.post(url)
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
|
.header(reqwest::header::CONTENT_LENGTH, "0");
|
||||||
|
debug!("Checking HTTP connection");
|
||||||
|
tokio::select! {
|
||||||
|
_ = notify.notified() => break,
|
||||||
|
r = req.send() => {
|
||||||
|
if let Err(e) = r {
|
||||||
|
error!("Error in HTTP request: {}", e);
|
||||||
|
tokio::select! {
|
||||||
|
_ = notify.notified() => break,
|
||||||
|
_ = backoff.sleep() => (),
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
backoff.reset();
|
||||||
|
debug!("HTTP connection successful");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (stream, handle) = relay.new_stream();
|
||||||
|
let stream = stream
|
||||||
|
.map(|v| serde_json::to_string(&v).map(|v| format!("{}\n", v)));
|
||||||
|
let body = reqwest::Body::wrap_stream(stream);
|
||||||
|
let req = client
|
||||||
|
.post(url)
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/stream+json")
|
||||||
|
.body(body);
|
||||||
|
info!("Starting HTTP sender stream");
|
||||||
|
if let Err(e) = req.send().await {
|
||||||
|
error!("HTTP request error: {}", e);
|
||||||
|
if let Err(e) = handle.await {
|
||||||
|
error!("Error in sender: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Channel closed, stopping HTTP sender");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_subscriber(
|
||||||
|
mut client: MqttClient,
|
||||||
|
tx: UnboundedSender<LogRecord>,
|
||||||
|
notify: &Notify,
|
||||||
|
) {
|
||||||
|
let mut stream = client.stream().await;
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = notify.notified() => return,
|
||||||
|
_ = client.reconnect() => (),
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = notify.notified() => {
|
||||||
|
info!("Stopping MQTT subscriber");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
msg = stream.next() => {
|
||||||
|
let Some(Some(msg)) = msg else {
|
||||||
|
error!("Lost connection to MQTT broker, reconnecting");
|
||||||
|
client.reconnect().await;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if msg.retained() {
|
||||||
|
debug!("Ignoring retained message on topic {}", msg.topic());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match LogRecord::try_from(msg) {
|
||||||
|
Ok(m) => {
|
||||||
|
if let Err(e) = tx.send(m) {
|
||||||
|
error!("mpsc channel error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Ignoring non-UTF8 payload: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_sender(
|
||||||
|
config: &Configuration,
|
||||||
|
chan: UnboundedReceiver<LogRecord>,
|
||||||
|
notify: Arc<Notify>,
|
||||||
|
) -> Result<JoinHandle<()>, reqwest::Error> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.tcp_keepalive(Duration::from_secs(10))
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.build()?;
|
||||||
|
let url = config.http.url.clone();
|
||||||
|
Ok(tokio::spawn(async move {
|
||||||
|
run_sender(client, &url, chan, ¬ify).await
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_subscriber(
|
||||||
|
config: &Configuration,
|
||||||
|
tx: UnboundedSender<LogRecord>,
|
||||||
|
notify: Arc<Notify>,
|
||||||
|
) -> Result<JoinHandle<()>, mqtt::MqttClientError> {
|
||||||
|
let client = MqttClient::new(config)?;
|
||||||
|
Ok(tokio::spawn(async move {
|
||||||
|
run_subscriber(client, tx, ¬ify).await
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_signal() -> Result<(), std::io::Error> {
|
||||||
|
let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate())?;
|
||||||
|
let mut sigint = tokio::signal::unix::signal(SignalKind::interrupt())?;
|
||||||
|
tokio::select! {
|
||||||
|
_ = sigterm.recv() => debug!("Got signal SIGTERM"),
|
||||||
|
_ = sigint.recv() => debug!("Got signal SIGINT"),
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = if let Some(arg) = std::env::args().nth(1) {
|
||||||
|
match Configuration::from_file(arg) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to load configuration: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Configuration::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let notify = Arc::new(Notify::new());
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let mqtt_task = match start_subscriber(&config, tx, notify.clone()).await {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to start MQTT subscriber: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let http_task = match start_sender(&config, rx, notify.clone()).await {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to start HTTP sender: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut rc = 0;
|
||||||
|
if let Err(e) = wait_signal().await {
|
||||||
|
error!("Error setting up signal handler: {}", e);
|
||||||
|
rc = 1;
|
||||||
|
}
|
||||||
|
notify.notify_waiters();
|
||||||
|
if let Err(e) = mqtt_task.await {
|
||||||
|
debug!("Error in MQTT subscriber task: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = http_task.await {
|
||||||
|
debug!("Error in HTTP sender task: {}", e);
|
||||||
|
}
|
||||||
|
std::process::exit(rc);
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use paho_mqtt as mqtt;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use crate::backoff::Backoff;
|
||||||
|
use crate::config::Configuration;
|
||||||
|
|
||||||
|
pub struct MqttClient {
|
||||||
|
url: String,
|
||||||
|
client: Arc<Mutex<mqtt::AsyncClient>>,
|
||||||
|
topics: Vec<String>,
|
||||||
|
backoff: Backoff,
|
||||||
|
|
||||||
|
ca_file: Option<PathBuf>,
|
||||||
|
client_cert: Option<PathBuf>,
|
||||||
|
client_key: Option<PathBuf>,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MqttClient {
|
||||||
|
pub fn new(config: &Configuration) -> Result<Self, MqttClientError> {
|
||||||
|
let url = config.mqtt.url.clone();
|
||||||
|
let ca_file = config.mqtt.ca_file.clone();
|
||||||
|
let client_cert = config.mqtt.client_cert.clone();
|
||||||
|
let client_key = config.mqtt.client_key.clone();
|
||||||
|
let username = config.mqtt.username.clone();
|
||||||
|
let password = if let Some(f) = &config.mqtt.password_file {
|
||||||
|
Some(
|
||||||
|
std::fs::read_to_string(f)
|
||||||
|
.map_err(MqttClientError::PasswordFile)?
|
||||||
|
.trim()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
config.mqtt.password.clone()
|
||||||
|
};
|
||||||
|
let opts = mqtt::CreateOptionsBuilder::new()
|
||||||
|
.server_uri(&config.mqtt.url)
|
||||||
|
.client_id(&config.mqtt.client_id)
|
||||||
|
.finalize();
|
||||||
|
let client = Arc::new(Mutex::new(mqtt::AsyncClient::new(opts)?));
|
||||||
|
let topics = config.mqtt.topics.clone();
|
||||||
|
let backoff = Backoff::default();
|
||||||
|
Ok(Self {
|
||||||
|
url,
|
||||||
|
client,
|
||||||
|
topics,
|
||||||
|
backoff,
|
||||||
|
ca_file,
|
||||||
|
client_cert,
|
||||||
|
client_key,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&mut self) -> Result<(), mqtt::Error> {
|
||||||
|
let client = self.client.lock().await;
|
||||||
|
client.connect(self.conn_opts()?).await?;
|
||||||
|
info!("Successfully connected to MQTT broker");
|
||||||
|
for topic in &self.topics {
|
||||||
|
debug!("Subscribing to topic: {}", topic);
|
||||||
|
client.subscribe(topic, 0).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reconnect(&mut self) {
|
||||||
|
while let Err(e) = self.connect().await {
|
||||||
|
error!("Reconnect failed: {}", e);
|
||||||
|
self.backoff.sleep().await;
|
||||||
|
}
|
||||||
|
self.backoff.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stream(
|
||||||
|
&mut self,
|
||||||
|
) -> mqtt::AsyncReceiver<Option<mqtt::Message>> {
|
||||||
|
self.client.lock().await.get_stream(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conn_opts(&self) -> Result<mqtt::ConnectOptions, mqtt::Error> {
|
||||||
|
let mut conn_opts = mqtt::ConnectOptionsBuilder::new();
|
||||||
|
if self.url.starts_with("mqtts://") || self.url.starts_with("ssl://") {
|
||||||
|
let mut ssl_opts = mqtt::SslOptionsBuilder::new();
|
||||||
|
ssl_opts.verify(true);
|
||||||
|
if let Some(ca_file) = &self.ca_file {
|
||||||
|
ssl_opts.trust_store(ca_file)?;
|
||||||
|
}
|
||||||
|
if let Some(client_cert) = &self.client_cert {
|
||||||
|
ssl_opts.key_store(client_cert)?;
|
||||||
|
}
|
||||||
|
if let Some(client_key) = &self.client_key {
|
||||||
|
ssl_opts.key_store(client_key)?;
|
||||||
|
}
|
||||||
|
let ssl_opts = ssl_opts.finalize();
|
||||||
|
conn_opts.ssl_options(ssl_opts);
|
||||||
|
}
|
||||||
|
if let [Some(username), Some(password)] =
|
||||||
|
[&self.username, &self.password]
|
||||||
|
{
|
||||||
|
conn_opts.user_name(username).password(password);
|
||||||
|
}
|
||||||
|
Ok(conn_opts.finalize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum MqttClientError {
|
||||||
|
#[error("MQTT client error: {0}")]
|
||||||
|
Mqtt(#[from] mqtt::Error),
|
||||||
|
#[error("Could not read password from file: {0}")]
|
||||||
|
PasswordFile(std::io::Error),
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
pub struct Relay<T> {
|
||||||
|
channel: Arc<Mutex<UnboundedReceiver<T>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Send + 'static> From<UnboundedReceiver<T>> for Relay<T> {
|
||||||
|
fn from(channel: UnboundedReceiver<T>) -> Self {
|
||||||
|
let channel = Arc::new(Mutex::new(channel));
|
||||||
|
Self { channel }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Send + 'static> Relay<T> {
|
||||||
|
pub async fn closed(&self) -> bool {
|
||||||
|
let chan = self.channel.lock().await;
|
||||||
|
chan.is_closed()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_stream(&self) -> (UnboundedReceiverStream<T>, JoinHandle<()>) {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let h = tokio::spawn({
|
||||||
|
let chan = self.channel.clone();
|
||||||
|
async move {
|
||||||
|
let mut chan = chan.lock().await;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
it = chan.recv() => {
|
||||||
|
if let Some(it) = it {
|
||||||
|
if tx.send(it).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("Upstream channel closed");
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tx.closed() => break,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(UnboundedReceiverStream::new(rx), h)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue