blender-open-data/launcher/DESIGN.md

5.4 KiB

Design

To understand why the design of the launcher is the way it is we need to first understand all the use cases. There are three ways the launcher can be used:

  1. using an interactive GUI aimed at normal users,
  2. using an interactive CLI aimed at running by users on servers using SSH,
  3. using a non-interactive CLI aimed at running in an automated fashion.

These use cases are implemented on top of a common core. This common core corresponds to:

  • internal/core contains functions for interfacing with the benchmark script and the platform,
  • internal/rpc contains functions for interfacing with /website and /launcher_authenticator.
  • internal/config contains the configuration.

CLI

The CLI interfaces are defined in internal/cli. The implementations are relatively simple wrappers around the core packages. Most of the code is parsing and validating command line arguments. The interactive mode is implemented in internal/cli/interactive.go the other files are for the non-interactive mode.

Interactive GUI

The interactive GUI is implemented in internal/ui using ImGui with a functional/immutable/Redux-like state management approach.

Writing a GUI is a bit harder than writing a CLI because we almost always have at least two threads modifying the state. This is because we always have one thread responsible for rendering the UI, which we cannot block because than the UI would stall. Therefore, if we want to do any blocking operations we will have to do them in a separate thread. We deal with this using a pattern explained in the following section.

State management

There are three main types we need to understand to understand the whole state management pattern. First, there is a centralized StateStore which is the single source of truth for the latest state. Second, the state itself is implemented as immutable structs implementing the State interface. Finally, State transitions are implemented as pure functions mapping a State to a new State, this is the StateTransform interface.

We initialize the pattern by instantiating a StateStore with an initial state. Afterwards, these three types work together using the StateStore.pushStateTransform method. This method accepts a StateTransform and pushes that onto a queue internal to the StateStore. Whenever we request the latest state in the main loop of the program using the StateStore.GetLatestState method these StateTransforms are applied to the last known state in a serialized fashion, and the resulting state is stored as the current state and returned.

This pattern works very well in our scenario because we have very limited concurrency issues: whenever the user is changing the state the computer is waiting and vice versa. Therefore we can just use the StateStore.pushStateNaive method which accepts a State and calls StateStore.pushStateTransform with a StateTransform that returns the former State. This amounts to serializing the state modifications using a last-write-wins strategy.

However, if in the future we do need concurrent state modification there are multiple ways to deal with that. The easiest is to partition the state into pieces which are not modified concurrently. Meaning that each part of the state is only written to in a serialized fashion. For example, this is the case when we have multiple threads reading from a specific part of the state but only one thread writing to that part.

If you really have multiple threads mutating the same part and there is no way of partitioning that part into independent pieces, you need to implement a StateTransform that handles the conflicts in a domain specific fashion. To illustrate, suppose we need to keep track of an integer value written to by multiple threads and we are interested in the highest value encountered. We could then implement a StateTransform that assigns to that field either the value of the field in the old state or the value we encountered in this thread, whichever is larger. This is an instance of what is called a CRDT. There are various patterns you can use for varying data types.

ImGui

ImGui is what is called an immediate mode UI library. This is dual to a retained mode UI library like Qt, or the DOM in a browser.

In a retained mode UI library the UI is defined using a set of widgets which encapsulate their state. The UI library encapsulating the state is problematic since we ourselves then no longer own that state, resulting in either duplicating the state and the corresponding synchronization issues, or having the UI library intrude into code which has nothing to do with the UI but just wants to modify some state.

In contrast, an immediate mode UI library gives you utility functions to render widgets and deal with user input. It is then your own responsibility to call the right widget rendering functions based on your state and to modify the state in reaction to the user input. This way we get to be the owner of our state whilst there still is a single source of truth.

The state management from the previous section composes nicely with ImGui. To add a UI we just need to implement a State.Render method accepting a State and which calls the right ImGui methods to render the required widgets whilst handling user input by calling StateStore.pushStateTransform with the required changes.