tarnish
A library for running code in isolated processes with automatic panic recovery.
Why?
Sometimes you need to run code that might crash. Maybe you're calling into a C library through FFI, and somewhere in that library there's a null pointer dereference in disguise? Or you're loading plugins from users and can't guarantee they won't panic. Or you're using a third-party sys-crate that wasn't audited yet.
Rust's type system can't protect you from segfaults in C code. It can't prevent
an abort() call in a dependency either. When those things happen, the entire
process terminates.
This library was born out of necessity to handle the specific problem of wrapping an untrusted sys-crate that made unsafe calls to C. With tarnish, the dangerous parts run in a separate process. If that crashes, the parent process detects the failure and spawns a fresh worker.
This pattern turns out to be useful for various kinds of systems programming. Any code where you can't guarantee stability can be isolated this way, which is why I generalized the pattern into a reusable library. That said, I haven't used it specifically outside of my narrow FFI use case yet, so be cautious still.
Features
- Process-level isolation (survives segfaults/FFI crashes)
- Automatic restart after crashes
- Trait-based API (not macro-based)
- Built-in type-safe message passing
- Production-focused
- General-purpose (any code, not just FFI)
Under The Hood
The trick is surprisingly simple.
You implement a Task trait that encapsulates your risky business logic. When
you spawn a worker, the library creates a fresh copy of your own binary, but
with a special environment variable set. Your main() function checks for this
variable at startup. If it's there, you know you're the worker subprocess, and
you should run the worker loop. If it's not there, you're the parent, and you
can spawn workers as needed.
This is conceptually similar to the classic fork pattern on Unix systems, but it
works on any platform that can spawn processes. The parent and worker
communicate over stdin and stdout, using messages which get serialized with
postcard, then base64 encoded so they
play nice with text streams. The concrete messaging format is an implementation
detail that you should not rely on, as it can change in future versions.
Serialization and deserialization happens automatically, so contrary to the Unix fork-exec model, you only need to worry about the business logic.
When a worker panics or crashes, the parent notices immediately. It spawns a fresh worker and retries the operation. If the crash was transient (cosmic ray, memory pressure, who knows), the retry succeeds. If it was deterministic (i.e., that input will always crash), the retry fails too, and you get an error back. Either way, your parent process keeps running.
Task Trait
The task trait looks like this:
Your input and output types just need to derive Serialize and Deserialize.
Everything else happens behind the scenes. You can also use types from the
standard library like String for both input and output if that's all you need.
There is a blanket implementation for those.
Example Use-Case: Wrapping Unsafe FFI
The original use-case is isolating unsafe FFI calls, so let's look at an example in that context.
use ;
use ;
;
// Define your input/output types
// Your unsafe FFI declaration
extern "C"
Note how main just calls tarnish::main() with the parent logic function.
This handles the check for parent-vs-task automatically.
Shutdown
When you drop a Process handle, it sends a shutdown message to the task and
waits up to 5 seconds. If the task doesn't exit cleanly, it gets a SIGKILL.
When Tasks Crash
When a task crashes mid-operation, process.call() automatically restarts the task and returns an error. The fresh task is ready for the next call.
You control the retry logic. Want to retry once?
// Try once, retry on failure
let result = process.call
.or_else;
Or implement more sophisticated retry logic with backoff, limits, etc. The library handles keeping a fresh task available, you decide when to retry.
Serialization format
Messages are currently serialized with postcard, a compact binary format that's roughly 10-20% the size of JSON. Messages are then base64 encoded before transmission.
Why base64? The parent and task communicate over stdin/stdout using a line-based protocol (one message per line). Postcard produces binary output which can contain newline bytes that would break our line delimiter. Base64 encoding ensures messages are text-safe. This adds ~33% overhead but is simple and correct. The alternative would be length-prefixed framing, which is more complex.
This is an implementation detail. Future versions might change the format, or let you pick your own. For now, postcard + base64 does the job well.
If you really don't want the serde dependency, you can disable the default
features and implement MessageEncode and MessageDecode manually for your
types. But honestly, you probably want serde.
Notes
Each task type gets its own environment variable based on its type name:
__TARNISH_WORKER_{TypeName}__. This means you can have multiple different
task types in the same binary without them stepping on each other's toes.
Tasks must implement Default (so we can spawn fresh ones) and be 'static
(no borrowed data, since they cross process boundaries).
Only tested on macOS and Linux. It probably works on other Unix-like systems. Windows support would require some work around process spawning and signal handling.
Similar Libraries
- Sandcrust - Academic research project for automatic sandboxing of unsafe components
- rusty-fork - Process isolation for tests (GitHub)
- Bastion - Fault-tolerant runtime with actor-model supervision
- rust_supervisor - Erlang/OTP-style supervision for Rust threads
- subprocess - Execution and interaction with external processes (GitHub)
- async-process - Asynchronous process handling
| Feature | tarnish | Sandcrust | rusty-fork | Bastion | rust_supervisor | subprocess/async-process |
|---|---|---|---|---|---|---|
| Process isolation | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ |
| Automatic restart | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ |
| Survives segfaults | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ |
| Production ready | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
| Built-in IPC | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ |
| Trait-based API | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ |
| FFI focus | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
| External commands | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Active maintenance | ✓ | ✗ | ✓ | ✓ | ✓ | ✓ |
About The Name
Tarnish is the protective layer that forms on metal when it's exposed to air. It looks like damage, but it's actually protecting the metal underneath from further corrosion. When you scratch it off, it just grows back.
I think you now understand where I'm going with this. This library is similar. The worker process is "the tarnish." It takes the hits so your main process doesn't have to. When it gets damaged, we regenerate it. The protection continues. This and the Rust pun.