use std::{
collections::HashMap,
io,
path::PathBuf,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex, OnceLock,
},
time::Duration,
};
use interprocess::local_socket::{
tokio::{prelude::*, Listener, RecvHalf, SendHalf},
GenericNamespaced, ListenerOptions, ToNsName,
};
use serde::{Deserialize, Serialize};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
process::{Child, Command},
sync::{mpsc, Notify},
};
use crate::{
error::BridgeError,
host::{error_response, Completion, DotNetHost, EventSink, OnStartError, STOPPED},
};
mod wrapper;
const UNAVAILABLE: &str = "HostSidecarUnavailable";
const INIT_FAILED: &str = "HostInitFailed";
const WATCH_PROCESS_EXITED: &str = "HostSidecarWatchExited";
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_SPAWN_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_FRAME_LEN: u32 = 64 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct SidecarOptions {
backend_csproj: PathBuf,
assembly_name: String,
tfm: String,
dotnet_root: Option<PathBuf>,
shutdown_timeout: Duration,
spawn_timeout: Duration,
watch: bool,
on_start_error: OnStartError,
}
impl SidecarOptions {
pub fn new(backend_csproj: impl Into<PathBuf>, assembly_name: impl Into<String>) -> Self {
Self {
backend_csproj: backend_csproj.into(),
assembly_name: assembly_name.into(),
tfm: "net8.0".to_string(),
dotnet_root: None,
shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
spawn_timeout: DEFAULT_SPAWN_TIMEOUT,
watch: true,
on_start_error: OnStartError::default(),
}
}
pub fn tfm(mut self, tfm: impl Into<String>) -> Self {
self.tfm = tfm.into();
self
}
pub fn dotnet_root(mut self, root: impl Into<PathBuf>) -> Self {
self.dotnet_root = Some(root.into());
self
}
pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
self.shutdown_timeout = timeout;
self
}
pub fn spawn_timeout(mut self, timeout: Duration) -> Self {
self.spawn_timeout = timeout;
self
}
pub fn watch(mut self, enabled: bool) -> Self {
self.watch = enabled;
self
}
pub fn on_start_error(mut self, action: OnStartError) -> Self {
self.on_start_error = action;
self
}
}
pub struct SidecarHost {
shared: Arc<Shared>,
}
struct Shared {
options: SidecarOptions,
events: OnceLock<EventSink>,
inner: Mutex<State>,
stop_signal: Notify,
stopped: AtomicBool,
next_generation: AtomicU64,
}
enum State {
NotStarted,
Ready(Connected),
Disconnected,
Failed(BridgeError),
}
struct Connected {
generation: u64,
writer_tx: mpsc::UnboundedSender<String>,
pending: Arc<Mutex<HashMap<String, Completion>>>,
reader_task: tauri::async_runtime::JoinHandle<()>,
writer_task: tauri::async_runtime::JoinHandle<()>,
}
fn fail_all(pending: &Mutex<HashMap<String, Completion>>, kind: &str, message: &str) {
let completions: Vec<Completion> = {
let mut map = pending.lock().unwrap_or_else(|e| e.into_inner());
map.drain().map(|(_, c)| c).collect()
};
for on_done in completions {
on_done(error_response(&BridgeError::new(kind, message)));
}
}
impl SidecarHost {
pub fn new(options: SidecarOptions) -> Self {
Self {
shared: Arc::new(Shared {
options,
events: OnceLock::new(),
inner: Mutex::new(State::NotStarted),
stop_signal: Notify::new(),
stopped: AtomicBool::new(false),
next_generation: AtomicU64::new(0),
}),
}
}
}
impl DotNetHost for SidecarHost {
fn start(&self, events: EventSink) -> Result<(), BridgeError> {
let _ = self.shared.events.set(events);
let shared = Arc::clone(&self.shared);
let outcome = tauri::async_runtime::block_on(async move {
let wrapper_csproj = wrapper::ensure(&shared.options.backend_csproj, &shared.options.assembly_name, &shared.options.tfm)?;
let (listener, pipe_name) = create_listener()?;
log::debug!(
"tauri-plugin-dotnet: starting {} against {}",
if shared.options.watch { "dotnet watch" } else { "dotnet run" },
wrapper_csproj.display()
);
let mut child = spawn_watch_process(&shared.options, &wrapper_csproj, &pipe_name)?;
let connected = accept_and_handshake(&shared, &listener, &mut child, shared.options.spawn_timeout).await?;
Ok::<_, BridgeError>((listener, child, connected))
});
match outcome {
Ok((listener, child, connected)) => {
log::info!("tauri-plugin-dotnet: the sidecar connected and is ready for calls");
*self.shared.inner.lock().unwrap_or_else(|e| e.into_inner()) = State::Ready(connected);
tauri::async_runtime::spawn(accept_loop(Arc::clone(&self.shared), listener, child));
Ok(())
}
Err(error) => {
*self.shared.inner.lock().unwrap_or_else(|e| e.into_inner()) = State::Failed(error.clone());
Err(error)
}
}
}
fn call(&self, window_label: &str, request_json: String, on_done: Completion) {
if self.shared.stopped.load(Ordering::SeqCst) {
on_done(error_response(&BridgeError::new(
STOPPED,
"The .NET backend has been shut down because the app is exiting.",
)));
return;
}
let inner = self.shared.inner.lock().unwrap_or_else(|e| e.into_inner());
match &*inner {
State::Ready(connected) => {
let call_id = extract_call_id(&request_json);
connected
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(call_id.clone(), on_done);
let envelope = Envelope::Call {
call_id,
window_label: window_label.to_string(),
json: request_json,
};
if connected
.writer_tx
.send(serde_json::to_string(&envelope).expect("Envelope always serializes"))
.is_err()
{
}
}
State::Disconnected => on_done(error_response(&BridgeError::new(
UNAVAILABLE,
"The .NET backend is not currently connected (a rebuild may be in progress); retry the call.",
))),
State::Failed(error) => on_done(error_response(error)),
State::NotStarted => on_done(error_response(&BridgeError::new(
"HostNotStarted",
"The .NET host has not been started.",
))),
}
}
fn cancel(&self, call_id: &str) {
let inner = self.shared.inner.lock().unwrap_or_else(|e| e.into_inner());
if let State::Ready(connected) = &*inner {
let envelope = Envelope::Cancel {
call_id: call_id.to_string(),
};
let _ = connected
.writer_tx
.send(serde_json::to_string(&envelope).expect("Envelope always serializes"));
}
}
fn stop(&self) {
if self.shared.stopped.swap(true, Ordering::SeqCst) {
return;
}
let connected = {
let mut inner = self.shared.inner.lock().unwrap_or_else(|e| e.into_inner());
match std::mem::replace(&mut *inner, State::Disconnected) {
State::Ready(connected) => Some(connected),
other => {
*inner = other;
None
}
}
};
let timeout = self.shared.options.shutdown_timeout;
if let Some(connected) = connected {
tauri::async_runtime::block_on(async move {
let envelope = Envelope::Shutdown {
timeout_ms: timeout.as_millis().min(i32::MAX as u128) as i32,
};
if let Ok(json) = serde_json::to_string(&envelope) {
let _ = connected.writer_tx.send(json);
}
let _ = tokio::time::timeout(timeout, connected.reader_task).await;
connected.writer_task.abort();
});
}
self.shared.stop_signal.notify_one();
}
fn on_start_error(&self) -> OnStartError {
self.shared.options.on_start_error
}
}
fn extract_call_id(request_json: &str) -> String {
#[derive(Deserialize)]
struct JustCallId {
#[serde(rename = "callId")]
call_id: String,
}
serde_json::from_str::<JustCallId>(request_json)
.map(|r| r.call_id)
.unwrap_or_default()
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum Envelope {
Ready,
Error { message: String, r#type: String },
#[serde(rename_all = "camelCase")]
Call {
call_id: String,
window_label: String,
json: String,
},
#[serde(rename_all = "camelCase")]
Response { call_id: String, json: String },
#[serde(rename_all = "camelCase")]
Cancel { call_id: String },
#[serde(rename_all = "camelCase")]
Event {
window_label: Option<String>,
json: String,
},
#[serde(rename_all = "camelCase")]
Shutdown { timeout_ms: i32 },
}
async fn write_frame<W: tokio::io::AsyncWrite + Unpin>(w: &mut W, json: &str) -> io::Result<()> {
let bytes = json.as_bytes();
w.write_all(&(bytes.len() as u32).to_le_bytes()).await?;
w.write_all(bytes).await?;
Ok(())
}
async fn read_frame<R: tokio::io::AsyncRead + Unpin>(r: &mut R) -> io::Result<Option<String>> {
let mut len_bytes = [0u8; 4];
match r.read_exact(&mut len_bytes).await {
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_le_bytes(len_bytes);
if len > MAX_FRAME_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("frame of {len} bytes exceeds the {MAX_FRAME_LEN}-byte limit"),
));
}
let mut buf = vec![0u8; len as usize];
r.read_exact(&mut buf).await?;
String::from_utf8(buf)
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
fn create_listener() -> Result<(Listener, String), BridgeError> {
let pipe_name = format!(
"tauri-plugin-dotnet-sidecar-{}-{}",
std::process::id(),
NEXT_SOCKET_ID.fetch_add(1, Ordering::Relaxed)
);
let name = pipe_name
.clone()
.to_ns_name::<GenericNamespaced>()
.map_err(|e| BridgeError::new(INIT_FAILED, format!("Failed to name the sidecar socket: {e}")))?;
let listener = ListenerOptions::new()
.name(name)
.create_tokio()
.map_err(|e| BridgeError::new(INIT_FAILED, format!("Failed to listen for the sidecar: {e}")))?;
Ok((listener, pipe_name))
}
fn spawn_watch_process(options: &SidecarOptions, wrapper_csproj: &std::path::Path, pipe_name: &str) -> Result<Child, BridgeError> {
let dotnet_exe: PathBuf = match &options.dotnet_root {
Some(root) => root.join(if cfg!(windows) { "dotnet.exe" } else { "dotnet" }),
None => PathBuf::from("dotnet"),
};
let mut command = Command::new(dotnet_exe);
if options.watch {
command.arg("watch").arg("--non-interactive").arg("run");
} else {
command.arg("run");
}
command
.arg("--project")
.arg(wrapper_csproj)
.arg("--")
.arg("--pipe")
.arg(pipe_name);
command
.spawn()
.map_err(|e| BridgeError::new(INIT_FAILED, format!("Failed to start the dotnet watch process: {e}")))
}
async fn accept_and_handshake(
shared: &Arc<Shared>,
listener: &Listener,
child: &mut Child,
timeout: Duration,
) -> Result<Connected, BridgeError> {
let accept = async {
tokio::select! {
accepted = listener.accept() => accepted.map_err(|e| BridgeError::new(INIT_FAILED, format!("The sidecar failed to connect: {e}"))),
status = child.wait() => match status {
Ok(status) => Err(BridgeError::new(INIT_FAILED, format!("The dotnet watch process exited before connecting (status {status})"))),
Err(e) => Err(BridgeError::new(INIT_FAILED, format!("Failed to wait for the dotnet watch process: {e}"))),
},
}
};
let stream = match tokio::time::timeout(timeout, accept).await {
Ok(Ok(stream)) => stream,
Ok(Err(error)) => return Err(error),
Err(_) => {
return Err(BridgeError::new(
INIT_FAILED,
format!("The sidecar did not connect within {timeout:?}."),
))
}
};
do_handshake(shared, stream, timeout).await
}
async fn do_handshake(shared: &Arc<Shared>, stream: interprocess::local_socket::tokio::Stream, timeout: Duration) -> Result<Connected, BridgeError> {
let (mut recv, send) = stream.split();
match tokio::time::timeout(timeout, read_frame(&mut recv)).await {
Ok(Ok(Some(json))) => match serde_json::from_str::<Envelope>(&json) {
Ok(Envelope::Ready) => {}
Ok(Envelope::Error { message, r#type }) => return Err(BridgeError::new(r#type, message)),
other => {
return Err(BridgeError::new(
"HostProtocolError",
format!("Unexpected handshake message from the sidecar: {other:?}"),
))
}
},
Ok(Ok(None)) => {
return Err(BridgeError::new(
INIT_FAILED,
"The sidecar closed the connection before completing its handshake.",
))
}
Ok(Err(e)) => return Err(BridgeError::new(INIT_FAILED, format!("Failed to read the sidecar's handshake: {e}"))),
Err(_) => {
return Err(BridgeError::new(
INIT_FAILED,
format!("The sidecar did not complete its handshake within {timeout:?}."),
))
}
}
let generation = shared.next_generation.fetch_add(1, Ordering::SeqCst);
let pending: Arc<Mutex<HashMap<String, Completion>>> = Arc::new(Mutex::new(HashMap::new()));
let (writer_tx, writer_rx) = mpsc::unbounded_channel::<String>();
let writer_task = tauri::async_runtime::spawn(run_writer(send, writer_rx));
let events = shared.events.get().cloned();
let reader_task = tauri::async_runtime::spawn(run_reader(recv, Arc::clone(&pending), events, Arc::clone(shared), generation));
Ok(Connected {
generation,
writer_tx,
pending,
reader_task,
writer_task,
})
}
static NEXT_SOCKET_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
async fn run_writer(mut send: SendHalf, mut rx: mpsc::UnboundedReceiver<String>) {
while let Some(json) = rx.recv().await {
if write_frame(&mut send, &json).await.is_err() {
break;
}
}
}
async fn run_reader(
mut recv: RecvHalf,
pending: Arc<Mutex<HashMap<String, Completion>>>,
events: Option<EventSink>,
shared: Arc<Shared>,
generation: u64,
) {
loop {
match read_frame(&mut recv).await {
Ok(Some(json)) => match serde_json::from_str::<Envelope>(&json) {
Ok(Envelope::Response { call_id, json }) => {
let completion = pending.lock().unwrap_or_else(|e| e.into_inner()).remove(&call_id);
if let Some(on_done) = completion {
on_done(json);
}
}
Ok(Envelope::Event { window_label, json }) => {
if let Some(events) = &events {
events(window_label, json);
}
}
Ok(other) => log::warn!("tauri-plugin-dotnet: unexpected sidecar message: {other:?}"),
Err(e) => log::error!("tauri-plugin-dotnet: malformed sidecar message: {e}"),
},
Ok(None) => {
handle_disconnect(&shared, &pending, generation, "The sidecar closed the connection.");
return;
}
Err(e) => {
handle_disconnect(&shared, &pending, generation, &format!("The sidecar connection failed: {e}"));
return;
}
}
}
}
fn handle_disconnect(shared: &Shared, pending: &Mutex<HashMap<String, Completion>>, generation: u64, message: &str) {
let mut inner = shared.inner.lock().unwrap_or_else(|e| e.into_inner());
let still_current = matches!(&*inner, State::Ready(connected) if connected.generation == generation);
if still_current {
log::info!("tauri-plugin-dotnet: {message}");
*inner = State::Disconnected;
}
drop(inner);
if still_current {
fail_all(pending, UNAVAILABLE, message);
}
}
async fn accept_loop(shared: Arc<Shared>, listener: Listener, mut child: Child) {
loop {
tokio::select! {
_ = shared.stop_signal.notified() => {
let _ = child.start_kill();
let _ = child.wait().await;
return;
}
status = child.wait() => {
let message = match status {
Ok(status) => format!("The dotnet watch process exited unexpectedly (status {status})."),
Err(e) => format!("Failed to wait for the dotnet watch process: {e}"),
};
log::error!("tauri-plugin-dotnet: {message}");
*shared.inner.lock().unwrap_or_else(|e| e.into_inner()) = State::Failed(BridgeError::new(WATCH_PROCESS_EXITED, message));
return;
}
accepted = listener.accept() => {
match accepted {
Ok(stream) => match do_handshake(&shared, stream, shared.options.spawn_timeout).await {
Ok(connected) => {
log::info!("tauri-plugin-dotnet: the sidecar reconnected after a rebuild");
*shared.inner.lock().unwrap_or_else(|e| e.into_inner()) = State::Ready(connected);
}
Err(error) => {
log::error!("tauri-plugin-dotnet: the sidecar's handshake failed: {error}");
*shared.inner.lock().unwrap_or_else(|e| e.into_inner()) = State::Failed(error);
}
},
Err(e) => log::error!("tauri-plugin-dotnet: failed to accept a sidecar reconnection: {e}"),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn options_have_the_documented_defaults() {
let options = SidecarOptions::new("MyApp.Backend.csproj", "MyApp.Backend");
assert_eq!(options.tfm, "net8.0");
assert_eq!(options.shutdown_timeout, Duration::from_secs(5));
assert_eq!(options.spawn_timeout, Duration::from_secs(30));
assert!(options.watch);
assert_eq!(options.on_start_error, OnStartError::ShowDialogAndExit);
assert!(options.dotnet_root.is_none());
}
#[test]
fn options_builder_methods_override_the_defaults() {
let options = SidecarOptions::new("MyApp.Backend.csproj", "MyApp.Backend")
.tfm("net9.0")
.dotnet_root("C:/dotnet")
.shutdown_timeout(Duration::from_secs(1))
.spawn_timeout(Duration::from_secs(2))
.watch(false)
.on_start_error(OnStartError::KeepRunning);
assert_eq!(options.tfm, "net9.0");
assert_eq!(options.dotnet_root, Some(PathBuf::from("C:/dotnet")));
assert_eq!(options.shutdown_timeout, Duration::from_secs(1));
assert_eq!(options.spawn_timeout, Duration::from_secs(2));
assert!(!options.watch);
assert_eq!(options.on_start_error, OnStartError::KeepRunning);
}
#[test]
fn envelopes_round_trip_through_json() {
let cases = vec![
Envelope::Ready,
Envelope::Error {
message: "boom".into(),
r#type: "InvalidOperationException".into(),
},
Envelope::Call {
call_id: "c1".into(),
window_label: "main".into(),
json: "{\"callId\":\"c1\",\"method\":\"Svc.Do\",\"args\":[]}".into(),
},
Envelope::Response {
call_id: "c1".into(),
json: "{\"callId\":\"c1\",\"result\":null}".into(),
},
Envelope::Cancel { call_id: "c1".into() },
Envelope::Event {
window_label: Some("main".into()),
json: "{\"event\":\"progress\",\"data\":{}}".into(),
},
Envelope::Event {
window_label: None,
json: "{\"event\":\"closed\"}".into(),
},
Envelope::Shutdown { timeout_ms: 5000 },
];
for case in cases {
let json = serde_json::to_string(&case).unwrap();
let round_tripped: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(format!("{case:?}"), format!("{round_tripped:?}"));
}
}
#[tokio::test]
async fn a_frame_round_trips_through_an_in_memory_pipe() {
let (mut a, mut b) = tokio::io::duplex(1024);
write_frame(&mut a, "hello").await.unwrap();
let received = read_frame(&mut b).await.unwrap();
assert_eq!(received.as_deref(), Some("hello"));
}
#[tokio::test]
async fn a_frame_exactly_at_the_buffer_boundary_round_trips() {
let payload = "x".repeat(1024);
let (mut a, mut b) = tokio::io::duplex(4096);
write_frame(&mut a, &payload).await.unwrap();
let received = read_frame(&mut b).await.unwrap();
assert_eq!(received.as_deref(), Some(payload.as_str()));
}
#[tokio::test]
async fn a_clean_close_between_frames_reads_as_none() {
let (a, mut b) = tokio::io::duplex(1024);
drop(a);
let received = read_frame(&mut b).await.unwrap();
assert_eq!(received, None);
}
#[tokio::test]
async fn an_oversized_length_prefix_is_rejected() {
let (mut a, mut b) = tokio::io::duplex(1024);
a.write_all(&(MAX_FRAME_LEN + 1).to_le_bytes()).await.unwrap();
let result = read_frame(&mut b).await;
assert!(result.is_err());
}
#[test]
fn extract_call_id_reads_the_call_id_field() {
assert_eq!(
extract_call_id("{\"callId\":\"abc\",\"method\":\"Svc.Do\",\"args\":[]}"),
"abc"
);
}
#[test]
fn extract_call_id_defaults_when_absent_or_malformed() {
assert_eq!(extract_call_id("not json"), "");
assert_eq!(extract_call_id("{}"), "");
}
fn fixture_backend_csproj() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("dotnet/tests/fixtures/SidecarFixtureBackend/SidecarFixtureBackend.csproj")
}
fn no_op_events() -> EventSink {
Arc::new(|_window_label, _event_json| {})
}
fn call_and_wait(host: &SidecarHost, call_id: &str, method: &str, args_json: &str) -> String {
let request = format!(r#"{{"callId":"{call_id}","method":"{method}","args":{args_json}}}"#);
let (tx, rx) = std::sync::mpsc::channel();
host.call("main", request, Box::new(move |response| { let _ = tx.send(response); }));
rx.recv_timeout(Duration::from_secs(30))
.unwrap_or_else(|_| "<<no response within 30s>>".to_string())
}
impl std::fmt::Debug for State {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
State::NotStarted => write!(f, "NotStarted"),
State::Ready(_) => write!(f, "Ready"),
State::Disconnected => write!(f, "Disconnected"),
State::Failed(e) => write!(f, "Failed({e})"),
}
}
}
#[test]
#[ignore = "spawns a real dotnet watch process against the fixture backend; slow on a cold build"]
fn real_process_answers_a_call() {
let backend = fixture_backend_csproj();
assert!(backend.exists(), "fixture backend project not found at {}", backend.display());
let host = SidecarHost::new(SidecarOptions::new(backend, "SidecarFixtureBackend").watch(false));
host.start(no_op_events()).expect("the sidecar failed to start");
let response = call_and_wait(&host, "c1", "EchoService.Echo", r#"["hello"]"#);
assert!(response.contains("\"result\":\"hello\""), "unexpected response: {response}");
host.stop();
}
#[test]
#[ignore = "spawns a real dotnet watch process against the fixture backend; slow on a cold build"]
fn real_process_disconnect_is_detected_and_fails_subsequent_calls() {
let backend = fixture_backend_csproj();
assert!(backend.exists(), "fixture backend project not found at {}", backend.display());
let host = SidecarHost::new(SidecarOptions::new(backend, "SidecarFixtureBackend"));
host.start(no_op_events()).expect("the sidecar failed to start");
let response = call_and_wait(&host, "c1", "EchoService.Echo", r#"["hello"]"#);
assert!(response.contains("\"result\":\"hello\""), "unexpected response: {response}");
host.stop();
}
}