Bailout Guard
When PHP triggers a “bailout” (via exit(), die(), or a fatal error), it uses
longjmp to unwind the stack. This bypasses Rust’s normal drop semantics,
meaning destructors for stack-allocated values won’t run. This can lead to
resource leaks for things like file handles, network connections, or locks.
The Problem
Consider this code:
#[php_function]
pub fn process_file(callback: ZendCallable) {
let file = File::open("data.txt").unwrap();
// If callback calls exit(), the file handle leaks!
callback.try_call(vec![]);
// file.drop() never runs
}
If the PHP callback triggers exit(), the File handle is never closed because
longjmp skips Rust’s destructor calls.
Solution 1: Using try_call
The simplest solution is to use try_call for PHP callbacks. It catches bailouts
internally and returns normally, allowing Rust destructors to run:
#[php_function]
pub fn process_file(callback: ZendCallable) {
let file = File::open("data.txt").unwrap();
// try_call catches bailout, function returns, file is dropped
let result = callback.try_call(vec![]);
if result.is_err() {
// Bailout occurred, but file will still be closed
// when this function returns
}
}
Solution 2: Using BailoutGuard
For cases where you need guaranteed cleanup even if bailout occurs directly
(not through try_call), use BailoutGuard:
use ext_php_rs::prelude::*;
use std::fs::File;
#[php_function]
pub fn process_file(callback: ZendCallable) {
// Wrap the file handle in BailoutGuard
let file = BailoutGuard::new(File::open("data.txt").unwrap());
// Even if bailout occurs, the file will be closed
callback.try_call(vec![]);
// Use the file via Deref
// file.read_to_string(...);
}
How BailoutGuard Works
-
Heap allocation: The wrapped value is heap-allocated so it survives the
longjmpstack unwinding. -
Cleanup registration: A cleanup callback is registered in thread-local storage when the guard is created.
-
On normal drop: The cleanup is cancelled and the value is dropped normally.
-
On bailout: Before re-triggering the bailout, all registered cleanup callbacks are executed, dropping the guarded values.
API
// Create a guard
let guard = BailoutGuard::new(value);
// Access the value (implements Deref and DerefMut)
guard.do_something();
let inner: &T = &*guard;
let inner_mut: &mut T = &mut *guard;
// Explicitly get references
let inner: &T = guard.get();
let inner_mut: &mut T = guard.get_mut();
// Extract the value, cancelling cleanup
let value: T = guard.into_inner();
Performance Note
BailoutGuard incurs a heap allocation. Only use it for values that absolutely
must be cleaned up, such as:
- File handles
- Network connections
- Database connections
- Locks and mutexes
- Other system resources
For simple values without cleanup requirements, the overhead isn’t worth it.
Nested Calls
BailoutGuard works correctly with nested function calls. Guards at all
nesting levels are cleaned up when bailout occurs:
#[php_function]
pub fn outer_function(callback: ZendCallable) {
let _outer_resource = BailoutGuard::new(Resource::new());
inner_function(&callback);
}
fn inner_function(callback: &ZendCallable) {
let _inner_resource = BailoutGuard::new(Resource::new());
// If bailout occurs here, both inner and outer resources are cleaned up
callback.try_call(vec![]);
}
Best Practices
-
Prefer
try_call: For most cases, usingtry_calland handling the error result is simpler and doesn’t require heap allocation. -
Use
BailoutGuardfor critical resources: Only wrap values that absolutely must be cleaned up (connections, locks, etc.). -
Don’t overuse: Not every value needs to be wrapped. Simple data structures without cleanup requirements don’t need
BailoutGuard. -
Combine approaches: Use
try_callwhere possible andBailoutGuardfor critical resources that must be cleaned up regardless.