I spent 2 hours yesterday trying to solve a crash bug on the iOS version of Toodle, the prototype app we are building to demonstrate sharing Rust storage libraries on multiple platforms. The crash was occurring when we released an
Item, a struct created by Rust, passed by pointer via C and accessed through a Swift wrapper. Deinitializing the Swift wrapper calls a Rust FFI function
item_destroy that reclaims ownership of the underlying pointer and releases it.
The fault lay in the function
item_destroy expecting one type of struct as an arg, and getting another. There is a reason for this and the discovery of that reason taught us several useful things about building Rust libraries to be shared across many different platforms.
Lesson 1: Don’t build on top of earlier prototypes without fully realising the baggage you are bringing with you.
Toodle was built from an app that was created during an earlier experiment in cross-platform Rust development. In this experiment, I was writing a series of tutorials about how to tackle different aspects of cross-platform Rust. Where I had gotten to when we paused those experiments and decided to use the app as a basis for a deeper exploration into a cross-platform Rust sync and storage was exploring how to pass complex objects across the FFI boundary, necessarily involving very object-oriented approach to structured data on the Rust side. This experiment was not completed at the time of pivot to another use.
Using this app as a base led us to building a Toodle OO style data model that was not really appropriate for the new usage, leading to a cumbersome FFI API. And also led us to
Lesson 2: Objects over FFI are complex.
Firstly, Rust FFI is
unsafe code as it is dealing with pointers that it has no ownership of.
Secondly, the way objects over Rust FFI work is, the native platform owns a raw pointer to the Rust object. To get a property of that object, the raw pointer needs to be passed back to Rust and a pointer to the property fetched from the object and passed back to the native code. For Swift, this needs to be done via a C bridging header. If you are using JNI for Java (don’t; use JNA if you want to stay sane), again you have to wrap your FFI API inside a JNI API. Each layer in between your Rust code and your native code is another place where things can go wrong, and those intermediate layers don’t have a shared debugging system to help you out.
Lesson 3: Make cross-platform decisions based on what is ideal for all platforms and not just a subset.
The original experiment had left us with a mostly completed OO Rust/iOS app. Swift and Java have been designed to work really well with C, C++ (and Objective-C for Swift). If you pass a
#repr(C) struct containing only simple types over the FFI boundary, these languages automatically create a wrapper around the pointer allowing you read-only access to the properties of that struct without having to call back into the FFI. This led us to deciding to convert our complex
Item object into an
ItemC object in the FFI boundary, allowing us to have simpler interaction.
Further more, Swift has annotations that can be added to the C header that allow you to specify functions in your FFI as setters and initializers, which Swift can then use to give you read-write access without you having to write your own wrapper objects.
These features made an OO approach to the data model much easier to deal with, but caused problems with our WebExtension JS interface, resulting in a clunky API that didn’t really meet any one platform’s needs entirely.
Lesson 4: Always remember to clean up after yourself.
When we moved the FFI from using complex Rust objects to a
repr(C) copies, we were in a bit of a hurry and so we didn’t clean up all the FFI functions that were no longer in use. Therefore the
destroy_item FFI function that took an
Item was not removed when we added the
destroy_item_c FFI function that took an
ItemC. The Swift code therefore continued to compile when we forgot to update the
deinit block to call
destroy_item_c. And it wasn’t until we deinitialized that Swift wrapper that things went wrong.
Lesson 5: You can’t rely on the Rust compiler checks over FFI.
We told Rust it was going to be getting an
Item, therefore it happily accepted the C pointer and tried to use it as an
Item. But it wasn’t an
Item, it was an
ItemC. If this had all been in Rust the compiler would have never let this happen, but that is not the world we were living in.
Lesson 6: Don’t be afraid to follow through on your decisions.
We realised that the OO approach to data modelling was the wrong thing for Toodle shortly after adding our second platform, but we were short on time, and there was lots to do and it’s a big risk making a breaking change especially when you have one working example that someone else spent a lot of time on.
We should have just bitten the bullet and made the change and rewritten the first client straight out.
Then again, maybe we wouldn’t have learned all these useful lessons…