The Developer’s Cry

Yet another blog by a hobbyist programmer

Rust and SDL2 : Fighting With Lifetimes (part 2)

Last time I started out trying to make a (small) game in Rust with SDL2. We implemented a TextureAtlas type and went all-out with lifetimes in order to pull it off. However, just when I thought we had overcome all troubles, our friend the borrow checker came back for vengeance. Now, I thought I knew enough about the borrow checker that it shouldn’t have been a problem … but things got really weird. And it turns out it’s not just a Rust thing — it’s specifically Rust-SDL2 that is insanely difficult to work with. Let me explain this a bit further.

Copper-top

So you want to write Rusty SDL game code. It won’t be long until you try making a texture manager. Something like this:

struct TextureManager {
    creator: TextureCreator<WindowContext>,
    textures: Vec<Texture>,
}

Nope. We need lifetimes, so it will be something like this:

struct TextureManager<'a> {
    creator: TextureCreator<WindowContext>,
    textures: Vec<Texture<'a>>,
}

// ... and in the impl<'a> we put:

fn load(&mut self, filename: &str) {
    let texture = self
        .creator
        .load_texture(filename)
        .unwrap();
    self.textures.push(texture);
}

This part compiles just fine. But when you try calling this function you get:

|
|     let tex1 = mgr.load("sprite1.png");
|                ^^^^^^^^^^^^^^^^^^^^^^^ borrowed value does not live
  long enough

...

| }
| -
| |
| `mgr` dropped here while still borrowed
| borrow might be used here, when `mgr` is dropped and runs the destructor
  for type `TextureManager<'_>`

The value does not live long enough? Why not? The manager lives in the main() function, what do you mean not long enough? Moreover it says the “borrow may be used here”, but that is the end of the main() function when every object in the program’s memory is supposed to die.

Let’s try to make friends and pass a reference rather than move the entire TextureCreator.

struct TextureManager<'a> {
    creator: &'a TextureCreator<WindowContext>,
    textures: Vec<Texture<'a>>,
}

Same thing, but now we get some more errors that are equally puzzling:

error[E0502]: cannot borrow `mgr` as immutable because it is also
borrowed as mutable
|
|     let tex1 = mgr.load("sprite1.png");
|                ----------------------- mutable borrow occurs here
|     let tex_query = mgr.query(tex1);
|                     ^^^^^^^^^^^^^^^
|                     |
|                     immutable borrow occurs here
|                     mutable borrow later used here```

and also this one:

error[E0502]: cannot borrow `mgr.textures` as immutable because it is also
borrowed as mutable
--> src/main.rs:107:17
|
|     let tex1 = mgr.load("sprite1.png");
|                ----------------------- mutable borrow occurs here

...

|     let tex2 = &mgr.textures[tex1];
|                 ^^^^^^^^^^^^
|                 |
|                 immutable borrow occurs here
|                 mutable borrow later used here```

Before you lecture me on borrows, realize that all of this is taking place in just the main() function. There’s nothing weird or intricate about this code … we call a struct method, and then some lines later, we call another struct method. I can draw out the memory layout, and show that there should be no problem. It is bonkers that borrowck falls over. Or is it?

I can change the code to work with integers or Strings, or I can write a demonstrator in which a File is used to populate a Vec<String>, with both being members of the same struct. But as soon as you put an SDL2 TextureCreator in a struct, there shall be trouble.

Trace Program: Running

Let’s dissect the borrowck errors and follow the code step by step, line by line.

let tex1 = mgr.load("sprite1.png");

We have our texture manager mgr and call the load() function. The function takes in a mutable self, so mgr is borrowed mutably.

fn load(&mut self, filename: &str) {
    let texture = self
        .creator
        .load_texture(filename)
        .unwrap();

The TextureCreator is stored in the struct, so we call load_texture() on the creator. That is an immutable borrow, and borrowck is not complaining so I guess it’s OK. The function returns a Texture on success.

    self.textures.push(texture);

Store (ie. move) the texture into our vector containing all textures. The vector now owns this texture; the TextureManager now owns this texture.

But wait a minute. The declaration of TextureCreator load_texture() is:

pub fn load_texture<P: AsRef<Path>>(
    &self,
    filename: P
) -> Result<Texture<'_>, String>
                   ^^^^

Where did this little bugger come from?

That is an “output” anonymous lifetime, and according to the Rust docs it means that all output locations have a single lifetime. Question: what exactly is meant by “single” here?

Explicit lifetime annotations in Rust are useful because they allow you to specify which inputs and outputs share the same lifetime. In this case however, there is no annotation in the input. The output lifetime appears from out of nowhere. So what does it represent? The lifetime of its parent stack frame? (This would indeed explain the strange borrowck behavior).

According to Rust-SDL2 devs the anonymous lifetime is necessary because the Texture is somehow tied to the canvas. They are forcing the textures to outlive the canvas.

The borrowck error messages are confusing, but I think the idea is that when the texture manager gets destroyed, it will also destroy all the textures it owns. If the canvas is still alive, it will have dangling references to textures. That is not allowed.

So we would have to find a way to tell Rust that the textures actually do live long enough. There is a special lifetime syntax that goes like

<'a, 'b: 'a>

Experimenting with this syntax did not yield satisfactory results. You can’t wave it away just like that.

The issue raises the question of why the Texture is tied to the Canvas in the first place. Quoting from the manual:

A texture can only be used by the Canvas it was originally created from

Probably the canvas also keeps a rendering context. For example, in OpenGL textures are handles. These handles are valid only in the associated GLcontext, which itself is associated to the window.

Know Thyself

Let’s have a glimpse at how this stuff is implemented. The declaration of Texture is:

pub struct Texture<'r> {
    raw: *mut sys::SDL_Texture,
    _marker: PhantomData<&'r ()>,
}

There is a raw pointer to an SDL_Texture. The SDL_Texture is in principle a black box that lives in “C” space. Rust can move the instance of Texture, but it can not touch the SDL_Texture. All the Rust borrow checker knows is that SDL_Texture is mutable data.

You would be naive to think that an SDL_Texture is just a struct with some dimensions and a pixel buffer. In reality the C memory map of SDL is a web of pointers. Something akin to this pseudo-code:

SDL_Texture {
    *prev, *next    // doubly linked list
    *renderer -> SDL_Renderer
}

SDL_Renderer {
    *texture -> SDL_Texture
    *window -> SDL_Window
}

SDL_Window {
    *prev, *next    // doubly linked list
    *surface -> SDL_Surface
}

Interesting, the textures are all linked together. In fact, everything is linked together. That’s all fine, and no harm done. But it makes you wonder why it was designed this way, and whether it makes sense.

There is no “Canvas” in the C implementation. For whatever reason, it only exists in the Rust version. The Canvas is a structure that ties a WindowContext (or a SurfaceContext) and a RendererContext together. These contexts are Rust wrappers for their SDL_ equivalents in C. The data structures are along these lines of pseudo-code:

Canvas {
    *target -> SurfaceContext | WindowContext
    *context -> RendererContext
    pixelformat
}

SurfaceContext {
    *raw -> SDL_Surface
}

WindowContext {
    VideoSubsystem
    *raw -> SDL_Window
}

RendererContext {
    *raw -> SDL_Renderer
}

TextureCreator {
    context -> RendererContext
    pixelformat
}

If you follow the pointers, then indeed eventually TextureCreator points at textures. I am not convinced beforehand that these structures must be linked together like this. This coding style is common in C, but I’m not sure it’s clever in Rust, because things like this tend to set off the borrow checker quickly.

I didn’t make SDL and I have great respect for the things it accomplishes. But there’s no denying that they crafted an API that is difficult to work with, and I’m certainly not the only person having issues with Rust-SDL2. Head over to StackOverflow and Reddit to find dozens of posts. Even guru-level programmers are stumped at the lifetime issue on textures. A similar thing happens for SDL fonts as well, and there are more places in the Rust-SDL2 API where output lifetimes pop into existence from seemingly out of nowhere.

I Know Kungfu

Rust-SDL2 actually ships with an example which demonstrates an asset loader, a ResourceManager. As an example code you would expect something simple; an easy example to get you started. The code looks like this:

type TextureManager<'l, T> = ResourceManager<'l, String, Texture<'l>, TextureCreator<T>>;
type FontManager<'l> = ResourceManager<'l, FontDetails, Font<'l, 'static>, Sdl2TtfContext>;

// Generic struct to cache any resource loaded by a ResourceLoader
pub struct ResourceManager<'l, K, R, L>
where
    K: Hash + Eq,
    L: 'l + ResourceLoader<'l, R>,
{
    loader: &'l L,
    cache: HashMap<K, Rc<R>>,
}

impl<'l, K, R, L> ResourceManager<'l, K, R, L>
where
    K: Hash + Eq,
    L: ResourceLoader<'l, R>,
{
    ...
}

Generics overload. You are looking at a generic struct with four (!) parameters,

It’s madness. If we decipher this thing, then we find that the struct holds a reference to TextureCreator and it uses a hashmap of refcounted Rc<Texture>s. Apparently this code does work. [I failed getting this particular example to work because it tried building SDL2_ttf which then failed on my system somehow … but anyway, sigh].

This inspired me to try the following:

struct TextureManager<'a> {
    creator: &'a TextureCreator<WindowContext>,
    textures: Vec<Rc<Texture<'a>>>,
}

and it actually works!

The texture creator is made in main(), as are all the other SDL contexts, and they are passed down as needed. The Rc creates another layer of indirection around the texture’s pointer, and that seems to satisfy the borrowing rules. When accessing the texture, we return a .clone() of the Rc. While I understand why this works, I’m still having difficulty with why the previous pattern does not.

Ignorance Is Bliss

A major problem that remains is that apparently you can not build your own API around the fundamental pillars of SDL. Trying to combine all of the SDL contexts into an all-encompassing struct again creates borrowck problems, so don’t touch it and just live with it. But but but … wrapping SDL’s init code is something I used to do in C/C++ all the time, if only to simplify the interface and bend it to my liking. It is utterly crazy and disappointing that such a thing would be impossible to do in Rust. You can “Rc” your way out of borrowck hell, but it’s a very unpleasant way of working.

Fundamentally it follows from the rules of the borrow checker that you can not always construct exactly the API that you want to have. Rust is nice until you try fighting its systems. I swear, next time someone tells me Rust is easy, I will challenge them to use crate sdl2 and implement a texture manager.

The absurdness of the situation is that you can enable “unsafe textures” in Rust-SDL2 to remove the lifetime annotation from struct Texture, and all problems simply vanish. As soon as you do this, you can have normal looking code, and you can wrap SDL’s init code.

Therefore I feel that it’s not Rust that is at fault here. I’ve had literally zero issues with borrowck since enabling “unsafe textures”. [I am a bit worried though about those other functions in the API that also happen to tag anonymous lifetimes onto output structures].

The Rust borrow checker typically kicks in when you are making a mistake. It is a tell-tale sign of bad design. It would have worked out better if the data structures had been independent of eachother. The current design follows the C design, where everything is linked together by pointers. It turns out not to be a good choice. I bet no one on the team had thought of that when they started the port to Rust, and so they ended up ‘fixing’ it by adding lifetimes. But most of all the Rust-SDL2 developers failed to recognize that their API must be simple to use. Simplicity beats complexity.

This adventure had me looking into ggez, another rather bare-bones gamedev library. The basics of ggez look dead simple and it should have gotten me going in less than an hour. Unfortunately its fancy modern wgpu renderer says my development virtual machine has no graphics card, so that was a bummer. SDL on the other hand runs accelerated OpenGL in it at 60 FPS.