Basic `Gooey` and `PliantDb` Example Development

Today, I started working on a simple example for Gooey and PliantDb. It’s very much a work in progress, and it was a lot more tricky than I had anticipated getting everything working. I want to talk about the challenges I faced and then offer up for review the solutions I picked today.

The first challenge was in PliantDb. The goal of the CustomApi was that the Request and Response types could be shared in a platform-neutral crate. Unfortunately, by putting Backend in pliantdb-core, that was impossible— simple oversight.

This allows the examples shared/lib.rs to be the Request, Response, and ExampleApi enums.

The server is pretty much straight out of all of the existing pliantdb examples.

The client is where all the remaining interesting projects happened.

Async Integration

PliantDb's client uses an async interface, and when running natively, it needs a tokio runtime. Kludgine supports tokio or smol currently, and Gooey doesn’t use async at all. Yet, as I tried to make async easy in this example, I couldn’t figure out how to do it.

I did find a cool project: async_executors. It makes it easier to initialize a cross-platform async environment using some traits. Unfortunately, there are some limitations, but the biggest issue I had is that the code was still too complex for my liking. The only way to make it easier was to add a very minimalistic spawn interface to App. These functions are only available if the async feature is enabled.

With it, the example’s initialization was made very straightforward: client/src/main.rs:23-36.

The WASM version is yet to be implemented but should be straightforward. The limitation on WASM support is that PliantDb needs a WASM client.

Communicating from another thread/task efficiently

@dAxpeDDa and I believe that using flume to communicate between sync and async code is a joy. As such, I use it in this example to communicate between the Gooey event handling code and an async task that is waiting for messages (client/src/main.rs:79-82):

let _ = component
     .behavior
     .command_sender
     .send(DatabaseCommand::Increment);

On the receiving end, though, what does that async task use to communicate back to Gooey? I decided the Context type was the best candidate and implemented Clone for it. I also added send_command_to() allowing the code to look like this (client/src/main.rs:111-125):

async fn increment_counter(client: &Client<ExampleApi>, context: &DatabaseContext) {
    match client.send_api_request(Request::IncrementCounter).await {
        Ok(response) => {
            let Response::CounterIncremented(count) = response;
            context.context.send_command_to::<Button>(
                &context.widget_id,
                ButtonCommand::SetLabel(count.to_string()),
            );
        }
        Err(err) => { /* ... */ }
    }
}

But how does that work? That’s the fun that consumed the remainder of my day. For WASM, this will be straightforward as long as you’re not using multithreading in the browser. Behind the scenes, the frontend could immediately process widget messages because you’re known to be in a single-threaded environment. Thus, Gooey can’t be in the middle of doing anything else.

But, on the Rasterizer level, things are a lot more complex. The Gooey window is implemented by Kludgine. Kludgine's app implementation has each window use a thread for its own event loop. The main winit thread dispatches events to each window’s thread.

After this change today, Kludgine now offers two ways to control the window’s event loop. You can implement two methods on the Window trait that are useful for this situation: update() and render().

Window::update() is always called before Window::render(). However, after update() is called, if the current RedrawStatus is not ready to redraw, Window::render() will not execute unless the OS has explicitly requested a redraw.

This took a lot longer to iron out than I had expected, but I’m pleased with the way Kludgine is operating now.

Updating the Kludgine App implementation required adding support to the Rasterizer frontend to be able to handle multiple possible situations:

  1. The PliantDb response comes back while no Gooey code is executing. In this situation, the Rasterizer invokes a callback that consumers can use to wake up their event loop. For Kludgine, that means invoking awaken().
  2. The PliantDb response comes back while Gooey is executing an update() or render(). In this situation, the Rasterizer will not do anything extra – those code paths will process widget messages already.

That may sound trivial, but here’s the actual breakdown of channel messages being sent to make this work, starting with the MouseUp event.

  1. Kludgine calls Window::process_input.
  2. App calls Rasterizer::handle_event.
  3. Rasterizer calls WidgetRasterizer::mouse_up.
  4. Button calls Callback::invoke.
  5. Counter calls flume::Sender::send with DatabaseCommand::Increment.
  6. process_database_commands invokes increment_counter which sends a request to the PliantDb server.
  7. Upon receiving the response (async), it calls Context::send_command_to to send ButtonCommand::SetLabel to the button.
  8. Button receives the command, updates its label, and posts the ButtonCommand as a transmogrifier command.
  9. The button transmogrifier receives the command
  • On WASM: calls HtmlElement::set_inner_text
  • On Rasterizer, calls set_needs_redraw on the frontend.

What do you think?

Check out the client example here and leave any thoughts, suggestions, or questions below!

I’m just glad I’m done debugging those event flows. As I commented in Discord earlier, my example reached triple digits of clicks before I finished debugging it all :slight_smile:

1 Like

Today marked the end of a long journey — the example now supports the browser. Edit: This example has been relocated here.

On the left, you can see the example running in Firefox. On the right, the example is running natively using Kludgine/wgpu. The client/main.rs file is everything necessary to:

  • Display the user interface
  • Call a custom API on the server that increments a counter
  • Receive PubSub notifications when any client increments the counter.

And, the best part? That file contains no #[cfg] attributes and no platform-specific code. That’s our goal with Gooey and PliantDb – make it easy to build cross-platform, database-driven applications.

How does it work on WebAssembly?

The main work needed to be done in PliantDb:

In the end, the work mostly entailed adding a second WebSocket implementation that used web-sys's WebSocket type. The rest of the work was changing a few dependencies to ones that would work in WebAssembly.

PliantDb’s API didn’t need to change on the surface because of excellent async support through wasm-bindgen-futures. Currently, PliantDb makes one assumption: in WebAssembly, you will not call its code from multiple threads.

If you’re not aware, each website in your browser uses cooperative threading to manage all JavaScript. Unless you specifically use special APIs to launch new workers, all WebAssembly and JavaScript code will execute in a single thread using cooperative scheduling. Because the browser is built for async interactions, this in-general works smoothly. However, there are limitations on what you can interact within those multithreaded workers, and PliantDb is not prepared to be invoked outside of the “main” thread.

What’s next?

The example works, but it needs to be documented. I think some of the documentation might deserve to be the start of a Gooey mdbook. Additionally, the PliantDb pull request has a few remaining todos before I would feel comfortable merging it into main.

But I’m genuinely excited. Yes, it’s just a button that clicks. But, it represents a significant milestone in the progress of developing the stack for Cosmic Verge. After we implement a few more widgets, we’ll be ready to get Cosmic Verge re-running on the new architecture finally!

I’ve made some significant progress in the last few days, but most of it has been improvements to the original WebAssembly client code for PliantDb. Most notably, the original error handling when I posted wasn’t ideal.

The Client for PliantDb aims to shield you from needing to worry too much about connection errors. It will automatically reconnect behind the scenes. You might ask, “how does it do that?”

I took a straightforward approach: creating a new client spawns a background task that processes requests. It doesn’t immediately try to connect – this is something I may offer a separate API to expose directly. Instead, it connects after the first request has been made. If there’s an error connecting, it’s returned with the initial request. The next request will try connecting again.

If a connection drops unexpectedly, all pending requests will receive disconnection errors. However, if the code that received those errors handles disconnections gracefully, the app can recover as the Client will just begin working again once a connection has been established.

While I haven’t figured out the unit testing strategy yet, I’m really happy with the state of the client code.

In terms of finishing this example up, it will be done in a couple of phases. To wrap up the current functionality, I want to add server-side atomic integer operations to the key-value store so that increment can be implemented correctly.

The next phase might involve replacing PubSub with a custom API broadcast on the server. Currently, we don’t support the features needed to do that, but it’s on the roadmap. That milestone is a little further down the road, but I might start working on some of it soon.