Rusty Dynamic Loading

Introduction

One of my favorite things that I’ve learned so far from Casey Muratori’s excellent Handmade Hero series of videos is his demonstration of how to load game code dynamically. This allows you to make changes to the running game without having to close the existing process, which enables very rapid iteration during development. However, Casey only shows you how to do it in C using Win32. In this post, I will demonstrate how to achieve the same basic effect using cross-platform Rust.

Even though this technique is primarily intended for game development, the purpose of this post is to demonstrate how to utilize dynamic libraries to reload code on the fly, which is a general technique that can be applied to any type of program that wants to take advantage of it.

NOTE: This post assumes some basic familiarity with Rust and Cargo, and that you have a working Rust development environment. If not, this is a good place to get started.

UPDATE: The end of this post now contains an updated final version using the libloading crate instead of dylib, since apparently it’s more actively maintained. The rest of the post is left unchanged.

Setting Up

In order for this to work, we first need to create a project defined by two halves: an executable, and a dynamic library. In a real-world scenario, the dynamic library will likely contain the vast majority of the project’s code; the executable’s sole purpose is to delegate functionality to the library and to reload it when necessary.

To get started, let’s create two projects using Cargo. In a new folder, run the following commands:

$ cargo new app
$ cargo new --bin main

Running these commands should give you the following project structure:

.
├── app
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── main
    ├── Cargo.toml
    └── src
        └── main.rs
  1. app is where the application logic lives, and will be built as a dynamic library.
  2. main is the executable that will load app and use it.

app

First, we need to make sure that app is configured to build a dynamic library. To do that, open up app/Cargo.toml and add a few lines at the bottom of the file:

[lib]
crate-type = ["dylib"]

These lines specify that the project should be built as a dynamic library rather than a static library, which is the default.

Now let’s implement some absolutely critical logic for our application in app/src/lib.rs:

#[no_mangle]
pub fn get_message() -> &'static str {
    "Hello, Dylib!"
}

Build the project with Cargo and ensure that it generates a dynamic library (which will likely appear under target/debug) by checking the library’s extension; if it was built as an .rlib file, then make sure you have your [lib] section set up correctly in Cargo.toml. The correct extension is .so, .dll, or .dylib for Linux, Windows, or Mac, respectively.

Okay, we now have a dynamic library that implements our application code. Let’s see how we can access it.

Hello World for Dylib

Let’s switch our attention over to the main project. In order to work with dynamic libraries, we first need to install the dylib crate, so add these lines to main/Cargo.toml before moving on:

[dependencies]
dylib = "0.0.2"

Now crack open main/src/main.rs in your editor of choice and let’s get to work! The first step is to use the dylib crate to retrieve a handle to the application code:

extern crate dylib;

use dylib::DynamicLibrary;
use std::path::Path;

// Change according to your setup and platform.
// This path assumes a Linux system, and that your working
// directory is `main`.
const LIB_PATH: &'static str = "../app/target/debug/libapp.so";

fn main() {
    let app = DynamicLibrary::open(Some(Path::new(LIB_PATH)))
        .unwrap_or_else(|error| panic!("{}", error));
}

(Note that in a more real-world scenario, you’ll probably want to handle errors more gracefully than just panicking.)

Now that we have a handle to the application library, how do we use it? This part is actually a little tricky since we lose a lot of type information when crossing process boundaries. The only utility provided for looking up a function in a dynamic library is the symbol() method, which returns a raw pointer to some type that you specify. But what type should we specify, and how do we safely dereference the raw pointer?

There are actually a few ways that you can do this, but after some wrangling with the type system, this is what I consider to be the best approach:

// In main(), right after opening the dynamic library.
let get_message: fn() -> &'static str = unsafe {
    std::mem::transmute(app.symbol::<usize>("get_message").unwrap())
};
println!("message: {}", get_message());

Since function references are just pointers, the first step is to look up the get_message symbol as a raw, untyped pointer (*mut usize), and then use transmute() to tell the Rust compiler to magically convert it to the type inferred by the variable we’re setting the result to; in this case, fn() -> &'static str, which we know matches the signature of the method’s implementation, even if the compiler doesn’t.

Note that this is also why we need to add the #[no_mangle] attribute above the function definition. By default, the Rust compiler mangles function names, which we need to prevent in order to look it up by name.

If all went well, then you should now be able to run the program and get the following output:

message: Hello, Dylib!

Listening for Changes

Now that we can call functions from our application, we now need to create a main loop. Let’s modify the code from above to repeatedly call get_message(), which will later be updated to dynamically pick up changes to that method:

loop {
    std::thread::sleep(std::time::Duration::from_secs(1));
    println!("message: {}", get_message());
}

But how do we know when to reload the application code? There are a couple different ways to do this:

  1. Use filesystem notifications (the notify crate).
  2. Query file metadata on each iteration, and reload if the file has changed.

Filesystem notifications provide the least amount of overhead in the main loop, but behave rather strangely when applied to binary files, so we’ll go with the second option. The most straightforward and cross-platform way to do this is to check the applications’s modification time, and reload it if the version on disk has been modified since it was last loaded into memory.

The first thing to do is to modify the existing code to make the app and get_message variables mutable, and to add a new mutable variable to keep track of the modification time.

let mut app = DynamicLibrary::open(Some(Path::new(LIB_PATH)))
    .unwrap_or_else(|error| panic!("{}", error));

let mut last_modified = std::fs::metadata(LIB_PATH).unwrap()
    .modified().unwrap();

let mut get_message: fn() -> &'static str = unsafe {
    std::mem::transmute(app.symbol::<usize>("get_message").unwrap())
};

Then we can modify the main loop to this:

let dur = std::time::Duration::from_secs(1);
loop {
    std::thread::sleep(dur);
    if let Ok(Ok(modified)) = std::fs::metadata(LIB_PATH)
                             .map(|m| m.modified())
    {
        if modified > last_modified {
            // TODO: Reload the application.
            last_modified = modified;
        }
    }
    println!("message: {}", get_message());
}

Reloading the Library

The only thing that’s left to do is to load the application’s new contents into memory. A first pass might look like this:

app = DynamicLibrary::open(Some(Path::new(LIB_PATH)))
    .unwrap_or_else(|error| panic!("{}", error));

get_message = unsafe {
    transmute(app.symbol::<usize>("get_message").unwrap())
};

This, however, won’t work. The correct implementation looks like this:

drop(app);

app = DynamicLibrary::open(Some(Path::new(LIB_PATH)))
    .unwrap_or_else(|error| panic!("{}", error));

get_message = unsafe {
    transmute(app.symbol::<usize>("get_message").unwrap())
};

The reason for the drop() is because most platforms (verified on Mac and Linux, and I assume Windows behaves similarly) cache the contents of each dynamic library loaded within the application, so that if it’s requested elsewhere, the application won’t have to reload it from disk. Unfortunately, that’s exactly what we want. In order to force the application to be reloaded from disk, we need to force the DynamicLibrary destructor to run first so that the reference count drops to 0, which causes the library to be unloaded from memory. Then we can reload it and get the contents as they are on disk.

The Full Solution

For those of you who skipped straight to the bottom, here’s the full solution for main:

extern crate dylib;

use dylib::DynamicLibrary;
use std::path::Path;

const LIB_PATH: &'static str = "../app/target/debug/libapp.so";

fn main() {
    // Open the application library.
    let mut app = DynamicLibrary::open(Some(Path::new(LIB_PATH)))
        .unwrap_or_else(|error| panic!("{}", error));

    // Look up the functions that we want to use.
    let mut get_message: fn() -> &'static str = unsafe {
        std::mem::transmute(
            app.symbol::<usize>("get_message").unwrap()
        )
    };

    // Record the time at which it was last modified.
    let mut last_modified = std::fs::metadata(LIB_PATH).unwrap()
        .modified().unwrap();

    // Begin looping once per second.
    let dur = std::time::Duration::from_secs(1);
    loop {
        std::thread::sleep(dur);
        if let Ok(Ok(modified)) = std::fs::metadata(LIB_PATH)
                                  .map(|m| m.modified())
        {
            // Check to see if the library has been modified
            // recently.
            if modified > last_modified {
                // Force the library's destructor to run, to avoid
                // retrieving a cached handle when reopening.
                drop(app);

                // Re-open the application library.
                app = DynamicLibrary::open(Some(Path::new(LIB_PATH)))
                    .unwrap_or_else(|error| panic!("{}", error));

                // Re-look up the functions that we want to use.
                get_message = unsafe {
                    std::mem::transmute(
                        app.symbol::<usize>("get_message").unwrap()
                    )
                };

                last_modified = modified;
            }
        }

        // Call your application methods here.
        println!("message: {}", get_message());
    }
}

Start this running, then modify app to print out G'day, Dylib! and build it. The output of main should adjust accordingly without skipping a beat!

Final Note: Sharing Custom Data

One big problem that any serious use of this technique will quickly run into is the sharing of custom data types. Both the application library and the running executable need to have access to the same custom type definitions in order to meaningfully pass the data between the two.

Fortunately, it’s not actually too difficult to do. If you add the application library as a dependency to main, then you can access any types defined there. To do that, just add a couple lines to main/Cargo.toml:

[dependencies.app]
path = "../app"

Then add an extern crate app; line at the top of main/src/main.rs, and you’re free to use any custom type defined within the app crate in your method definitions. The only caveat is that main must be restarted after any changes to your data types, but those should be changing must less frequently than the code that uses them.

Update: Using libloading

Since writing this post, it has been brought to my attention that the dylib crate is not actively maintained, and that libloading is a better alternative. Here’s one way to achieve the same effect using libloading:

extern crate app;
extern crate libloading;

use libloading::Library;

const LIB_PATH: &'static str = "../app/target/debug/libapp.so";

struct Application(Library);
impl Application {
    fn get_message(&self) -> &'static str {
        unsafe {
            let f = self.0.get::<fn() -> &'static str>(
                b"get_message\0"
            ).unwrap();
            f()
        }
    }
}

fn main() {
    let mut app = Application(Library::new(LIB_PATH)
        .unwrap_or_else(|error| panic!("{}", error)));

    let mut last_modified = std::fs::metadata(LIB_PATH).unwrap()
        .modified().unwrap();

    let dur = std::time::Duration::from_secs(1);
    loop {
        std::thread::sleep(dur);
        if let Ok(Ok(modified)) = std::fs::metadata(LIB_PATH)
                                  .map(|m| m.modified())
        {
            if modified > last_modified {
                drop(app);
                app = Application(Library::new(LIB_PATH)
                    .unwrap_or_else(|error| panic!("{}", error)));
                last_modified = modified;
            }
        }
        println!("message: {}", app.get_message());
    }
}

The primary difference here is the introduction of the Application type, which is just a wrapper around the dynamic library. The reason for this is that libloading, being a safer alternative to dylib, pretty strictly enforces how long a symbol reference can be valid for; if you fetch and maintain a reference the same way we did with dylib, the compiler will bark at you when you try to do anything else with the library, since it’s borrowed until the reference goes out of scope. The Application type wraps the library and looks up symbol references on the fly, which gets around the problem with the possibility of a slight performance hit. If the performance hit becomes unacceptable, it is possible to maintain a symbol reference by using into_raw(), but that’s left as an exercise for the reader.