The complete source code for this post is available at https://github.com/rfdonnelly/tauri-async-example.

1. Goal

Integrate an async Rust process into a Tauri application. More specifically, perform bidirectional communication between the Tauri webview and an async Rust process where either side can initiate.

The Tauri main thread manages both the webview and the async process. The main thread sits between the two.

A diagram of our desired Tauri application
Figure 1. A diagram of our desired Tauri application

We can break this up into two smaller problems: bidirectional communication between,

  • the webview (JavaScript) and the main thread (Rust)
  • the main thread (Rust) and the async process (Rust)

2. Create a Tauri App

First, we need to create a Tauri application.

Follow the Tauri Getting Started instructions for installing the necessary prerequisites.

Run the create-tauri-app utility

npm create tauri-app

And make the following entries/selections

? What is your app name? tauri-async
? What should the window title be? Tauri App
? What UI recipe would you like to add? create-vite
? Add "@tauri-apps/api" npm package? Yes
? Which vite template would you like to use? vue

Then build and run the application

cd tauri-async
npm install
npm run tauri dev
The default application window for the Tauri Vite + Vue template
Figure 2. The Tauri application with the Vite + Vue template

3. The Async Process

Next, we need to know what our async process looks like. We’ll keep it abstract to make this applicable to more applications.

Diagram

The async process will take input via a tokio::mpsc (Multi-Producer, Single-Consumer) channel and give output via another tokio::mpsc channel.

We’ll create an async process model that acts as a sit-in for any specifc use case. The model is an async function with a loop that takes strings from the input channel and returns them on the output channel.

Our async process model in src-tauri/src/main.rs

use tokio::sync::mpsc;

// ...

async fn async_process_model(
    mut input_rx: mpsc::Receiver<String>,
    output_tx: mpsc::Sender<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    loop {
        while let Some(input) = input_rx.recv().await {
            let output = input;
            output_tx.send(output).await?;
        }
    }
}

Even though Tauri uses and re-exports some of the Tokio types (via the tauri::async_runtime module), it doesn’t re-export everything we need. So we’ll need to add Tokio. We’ll also add Tracing and Tracing Subscriber while were at it.

cd tauri-src
cargo add tokio --features full
cargo add tracing tracing-subscriber

4. Bidirectional Communication between Rust and JavaScript

Diagram

Tauri provides two mechanism for communicating between Rust and JavaScript: Events and Commands. The Tauri docs for Commands and Events do a good job of covering these.

4.1. Commands vs Events

Events can be sent in either direction while Commands can only be sent from JavaScript to Rust.

I prefer Commands for sending messages from JavaScript to Rust. Commands automate a lot of the boiler plate like message deserialization and state management. So while we could use Events for everything, Commands are more ergonomic.

4.2. Possible Simplification

You can get by with only async Tauri Commands (i.e. without Tauri Events) if:

  • JavaScript initiates all communication
  • Requests/responses are one-to-one or one-to-none

Otherwise, you also need Tauri Events. In this post, the goal is to allow either side to initiate communication. This requires the use of Events.

4.3. The JavaScript Side

On the JavaScript side we use the invoke and listen Tauri APIs to send Commands and receive Events respectively.

I rewrote the HelloWorld Vue component that is created by the create-tauri-app utility to provide an interface for sending messages to Rust and reporting messages in both directions.

Replace the content of src/components/HelloWorld.vue with the listing below. The interesting parts are the sendOutput() function and the call to listen().

<script setup>
import { ref } from 'vue'
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/tauri'

const output = ref("");
const outputs = ref([]);
const inputs = ref([]);

function sendOutput() {
  console.log("js: js2rs: " + output.value)
  outputs.value.push({ timestamp: Date.now(), message: output.value }) 2
  invoke('js2rs', { message: output.value }) 3
}

await listen('rs2js', (event) => { 4
  console.log("js: rs2js: " + event)
  let input = event.payload
  inputs.value.push({ timestamp: Date.now(), message: input }) 5
})
</script>

<template>
  <div style="display: grid; grid-template-columns: auto auto;">
    <div style="grid-column: span 2; grid-row: 1;">
      <label for="input" style="display: block;">Message</label>
      <input id="input" v-model="output">
      <br>
      <button @click="sendOutput()">Send to Rust</button> 1
    </div>
    <div style="grid-column: 1; grid-row: 2;">
      <h3>js2rs events</h3>
      <ol>
        <li v-for="output in outputs">
          {{output}}
        </li>
      </ol>
    </div>
    <div style="grid-column: 2; grid-row: 2;">
      <h3>rs2js events</h3>
      <ol>
        <li v-for="input in inputs">
          {{input}}
        </li>
      </ol>
    </div>
  </div>
</template>
  1. Clicking the button calls sendOutput()
  2. Add the 'js2rs' message to the outputs array to show the user what was sent
  3. Send the 'js2rs' message to Rust via the Tauri invoke API
  4. Setup a listener for the 'rs2js' event via the Tauri listen API
  5. Add the 'rs2js' message to the inputs array to show the user what was received

4.3.1. An Aside: The (lack of) <Suspense> is Killing Me

If we run the application now, the HelloWorld world component is no longer rendered. If we open the JavaScript console, we find an error.

A component with async setup() must be nested in a <Suspense>
Figure 3. "A component with async setup() must be nested in a <Suspense>"

The HelloWorld component is now awaiting an async function in <script setup>. When a Vue component includes a top-level await statement in <script setup>, the Vue component must be placed in a <Suspense> component.

To fix, modify src/App.vue as follows

-  <HelloWorld/>
+  <Suspense>
+    <HelloWorld/>
+  </Suspense>

4.3.2. Result

If we run the application again, it looks like

The Tauri application after the modifications to HelloWorld.vue
Figure 4. The Tauri application after the modifications to the HelloWorld component

4.4. The Rust Side

Here is the Rust side of the bidirectional communication between the main thread and the webview. Most of the bidirectional communication between the main thread and the async process has been commented out.

use tauri::Manager;
use tokio::sync::mpsc;

// ...

fn main() {
    // ...

    let (async_proc_input_tx, async_proc_input_rx) = mpsc::channel(1);
    let (async_proc_output_tx, mut async_proc_output_rx) = mpsc::channel(1);

    tauri::Builder::default()
        // ...
        .invoke_handler(tauri::generate_handler![js2rs])
        .setup(|app| {
            // ...

            let app_handle = app.handle();
            tauri::async_runtime::spawn(async move {
                // A loop that takes output from the async process and sends it
                // to the webview via a Tauri Event
                loop {
                    if let Some(output) = async_proc_output_rx.recv().await {
                        rs2js(output, &app_handle);
                    }
                }
            });

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

// A function that sends a message from Rust to JavaScript via a Tauri Event
fn rs2js<R: tauri::Runtime>(message: String, manager: &impl Manager<R>) {
    info!(?message, "rs2js");
    manager
        .emit_all("rs2js", message)
        .unwrap();
}

// The Tauri command that gets called when Tauri `invoke` JavaScript API is
// called
#[tauri::command]
async fn js2rs(
    message: String,
    state: tauri::State<'_, AsyncProcInputTx>,
) -> Result<(), String> { 1
    info!(?message, "js2rs");
    // ...
}
  1. Stateful async Tauri Commands must return a Result (see tauri-apps/tauri#2533).

5. Bidirectional Communication between the Main Thread and the Async Process

Diagram

Passing messages between Rust and JavaScript may be straightforward but doing so between the Tauri main thread and an async process is a little more involved.

The inputs and outputs of the async process are implemented as tokio::mpsc (Multi-Producer, Single-Consumer) channels. We only have a single producer but there isn’t a more specific persistent channel primitive for single-producer, single-consumer. There is tokio::oneshot which is single-producer, single-consumer but as the name implies, it can only send a single value ever.

5.1. An Aside: Who Owns the Async Runtime?

By default, Tauri owns and initializes the Tokio runtime. Because of this, you don’t need an async main and a #[tokio::main] annotation.

For additional flexibility, Tauri allows us to own and initialize the Tokio runtime ourselves. We can do this by adding the #[tokio::main] annotation, adding async to main, and then telling Tauri to use our Tokio runtime.

#[tokio::main]
async fn main() {
    tauri::async_runtime::set(tokio::runtime::Handle::current());

    // ...
}

5.1.1. Inside Tauri

If we make all of our async calls inside of Tauri, then Tauri can own and manage the Tokio runtime.

fn main() {
    // ...

    tauri::Builder::default()
        .setup(|app| {
            tokio::spawn(async move {
                async_process(
                    async_process_input_rx,
                    async_process_output_tx,
                ).await
            });

            Ok(())
        }
        // ...
}

This is the method we’re going to use because it is slightly simpler.

5.1.2. Outside Tauri

If we make any async calls outside of Tauri, then we need to own and manage the Tokio runtime.

#[tokio::main]
async fn main() {
    tauri::async_runtime::set(tokio::runtime::Handle::current());

    // ...

    tokio::spawn(async move {
        async_process(
            async_process_input_rx,
            async_process_output_tx,
        ).await
    });

    tauri::Builder::default()
        // ...
}

5.2. Creating the Channels

The tokio::mpsc channels need to be created for both directions: inputs to the async process and outputs from the async process.

fn main() {
    // ...

    let (async_process_input_tx, async_process_input_rx) = mpsc::channel(1);
    let (async_process_output_tx, async_process_output_rx) = mpsc::channel(1);

    // ...
}

5.3. Running the Async Process

We’ll have Tauri own and manage the Tokio runtime so we’ll need to run the async process inside tauri::Builder::setup().

fn main() {
    // ...

    let (async_process_input_tx, async_process_input_rx) = mpsc::channel(1);
    let (async_process_output_tx, async_process_output_rx) = mpsc::channel(1);

    tauri::Builder::default()
        // ...
        .setup(|app| {
            tokio::spawn(async move {
                async_process(
                    async_process_input_rx,
                    async_process_output_tx,
                ).await
            });

            Ok(())
        }
        // ...
}

5.4. Main Thread to Async Process

Diagram

Sending messages from the main thread to the async process requires more sophistication. This additional sophistication is dictated by the need for our command to have mutable access to input channel for the async process.

To review, the main thread receives a message from JavaScript via a Tauri Command. The Command then needs to forward the message to the async process via input channel for the async process. The Command needs access to the channel. So how do we get give the Command access to the input channel?

The answer is tauri::State<T>. We can use Tauri’s state management system to pass the input channel to the Command. The Tauri Command guide covers state management but it is missing a key piece. Mutability.

We need mutable access to the input channel but Tauri managed state is immutable and what good is state if you can mutate it? How do we get mutable access to the input channel via immutable state?

The answer is interior mutability and "the most basic type for interior mutability that supports concurrency is Mutex<T>"[1].

We can’t use std::sync::Mutex<T> because we need to .await a send() on the input channel and the guard for std::sync::Mutex<T> cannot be held across an .await. However, the guard for tokio::sync::Mutex<T> can!

First, we create a struct that wraps a mutex on the input channel.

struct AsyncProcInputTx {
    inner: Mutex<mpsc::Sender<String>>,
}

This wrapper struct simplifies the type signature. Instead of having to write Mutex<mpsc::Sender<String>> everywhere, we only have to write AsyncProcInputTx.

Then, we put our input channel into a mutex, put the mutex into our wrapper struct, and hand it off to Tauri to manage via tauri::Builder::manage.

fn main() {
    // ...

    tauri::Builder::default()
        .manage(AsyncProcInputTx {
            inner: Mutex::new(async_proc_input_tx),
        })
        // ...
}

Finally, we can access this immutable state in our command, take a lock on the Mutex to get mutable access to the input channel, put the message in the channel, and implicitly unlock the Mutex when the guard goes out of scope at the end of the function.

#[tauri::command]
async fn js2rs(message: String, state: tauri::State<'_, AsyncProcInputTx>) -> Result<(), String> {
    info!(?message, "js2rs");
    let async_proc_input_tx = state.inner.lock().await;
    async_proc_input_tx
        .send(message)
        .await
        .map_err(|e| e.to_string())
}

5.5. Async Process to Main Thread

Diagram

In comparison, sending messages from the async process to the main thread is trivial.

We spawn an async process that pulls messages out of the output channel and forwards them to our rs2js function.

fn main() {
    // ...

    tauri::Builder::default()
        // ...
        .setup(|app| {
            // ...

            let app_handle = app.handle();
            tauri::async_runtime::spawn(async move {
                loop {
                    if let Some(output) = async_proc_output_rx.recv().await {
                        rs2js(output, &app_handle);
                    }
                }
            });

            Ok(())
        })
        // ...
}

6. Result

The following demo shows three messages "a", "b", and "c" sent from the webview to the async Rust process and back.

  1. When the "Send to Rust" button is clicked, the frontend
    1. reports the message in the "js2rs events" portion of the page,
    2. and sends the message to the main thread.
  2. The main thread
    1. receives the message,
    2. reports the message in the terminal,
    3. and sends the message to the async process.
  3. The async process,
    1. receives the message
    2. and sends the message back to the main thread.
  4. The main thread,
    1. receives the message,
    2. reports the message in the terminal,
    3. and sends the message to the frontend.
  5. The frontend,
    1. receives the message,
    2. and reports the message in the "rs2js events" portion of th page.
Demo of bidirectional communication between the webview and an async Rust process

Here is the complete Rust code in src-tauri/src/main.rs

#![cfg_attr(
    all(not(debug_assertions), target_os = "windows"),
    windows_subsystem = "windows"
)]

use tauri::Manager;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tracing::info;
use tracing_subscriber;

struct AsyncProcInputTx {
    inner: Mutex<mpsc::Sender<String>>,
}

fn main() {
    tracing_subscriber::fmt::init();

    let (async_proc_input_tx, async_proc_input_rx) = mpsc::channel(1);
    let (async_proc_output_tx, mut async_proc_output_rx) = mpsc::channel(1);

    tauri::Builder::default()
        .manage(AsyncProcInputTx {
            inner: Mutex::new(async_proc_input_tx),
        })
        .invoke_handler(tauri::generate_handler![js2rs])
        .setup(|app| {
            tauri::async_runtime::spawn(async move {
                async_process_model(
                    async_proc_input_rx,
                    async_proc_output_tx,
                ).await
            });

            let app_handle = app.handle();
            tauri::async_runtime::spawn(async move {
                loop {
                    if let Some(output) = async_proc_output_rx.recv().await {
                        rs2js(output, &app_handle);
                    }
                }
            });

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

fn rs2js<R: tauri::Runtime>(message: String, manager: &impl Manager<R>) {
    info!(?message, "rs2js");
    manager
        .emit_all("rs2js", format!("rs: {}", message))
        .unwrap();
}

#[tauri::command]
async fn js2rs(
    message: String,
    state: tauri::State<'_, AsyncProcInputTx>,
) -> Result<(), String> {
    info!(?message, "js2rs");
    let async_proc_input_tx = state.inner.lock().await;
    async_proc_input_tx
        .send(message)
        .await
        .map_err(|e| e.to_string())
}

async fn async_process_model(
    mut input_rx: mpsc::Receiver<String>,
    output_tx: mpsc::Sender<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    while let Some(input) = input_rx.recv().await {
        let output = input;
        output_tx.send(output).await?;
    }

    Ok(())
}

  1. From Rust-101, Part 15: Mutex, Interior Mutability (cont.), RwLock, Sync