|
| 1 | +# Bailout Guard |
| 2 | + |
| 3 | +When PHP triggers a "bailout" (via `exit()`, `die()`, or a fatal error), it uses |
| 4 | +`longjmp` to unwind the stack. This bypasses Rust's normal drop semantics, |
| 5 | +meaning destructors for stack-allocated values won't run. This can lead to |
| 6 | +resource leaks for things like file handles, network connections, or locks. |
| 7 | + |
| 8 | +## The Problem |
| 9 | + |
| 10 | +Consider this code: |
| 11 | + |
| 12 | +```rust,ignore |
| 13 | +#[php_function] |
| 14 | +pub fn process_file(callback: ZendCallable) { |
| 15 | + let file = File::open("data.txt").unwrap(); |
| 16 | +
|
| 17 | + // If callback calls exit(), the file handle leaks! |
| 18 | + callback.try_call(vec![]); |
| 19 | +
|
| 20 | + // file.drop() never runs |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +If the PHP callback triggers `exit()`, the `File` handle is never closed because |
| 25 | +`longjmp` skips Rust's destructor calls. |
| 26 | + |
| 27 | +## Solution 1: Using `try_call` |
| 28 | + |
| 29 | +The simplest solution is to use `try_call` for PHP callbacks. It catches bailouts |
| 30 | +internally and returns normally, allowing Rust destructors to run: |
| 31 | + |
| 32 | +```rust,ignore |
| 33 | +#[php_function] |
| 34 | +pub fn process_file(callback: ZendCallable) { |
| 35 | + let file = File::open("data.txt").unwrap(); |
| 36 | +
|
| 37 | + // try_call catches bailout, function returns, file is dropped |
| 38 | + let result = callback.try_call(vec![]); |
| 39 | +
|
| 40 | + if result.is_err() { |
| 41 | + // Bailout occurred, but file will still be closed |
| 42 | + // when this function returns |
| 43 | + } |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +## Solution 2: Using `BailoutGuard` |
| 48 | + |
| 49 | +For cases where you need guaranteed cleanup even if bailout occurs directly |
| 50 | +(not through `try_call`), use `BailoutGuard`: |
| 51 | + |
| 52 | +```rust,ignore |
| 53 | +use ext_php_rs::prelude::*; |
| 54 | +use std::fs::File; |
| 55 | +
|
| 56 | +#[php_function] |
| 57 | +pub fn process_file(callback: ZendCallable) { |
| 58 | + // Wrap the file handle in BailoutGuard |
| 59 | + let file = BailoutGuard::new(File::open("data.txt").unwrap()); |
| 60 | +
|
| 61 | + // Even if bailout occurs, the file will be closed |
| 62 | + callback.try_call(vec![]); |
| 63 | +
|
| 64 | + // Use the file via Deref |
| 65 | + // file.read_to_string(...); |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +### How `BailoutGuard` Works |
| 70 | + |
| 71 | +1. **Heap allocation**: The wrapped value is heap-allocated so it survives |
| 72 | + the `longjmp` stack unwinding. |
| 73 | + |
| 74 | +2. **Cleanup registration**: A cleanup callback is registered in thread-local |
| 75 | + storage when the guard is created. |
| 76 | + |
| 77 | +3. **On normal drop**: The cleanup is cancelled and the value is dropped normally. |
| 78 | + |
| 79 | +4. **On bailout**: Before re-triggering the bailout, all registered cleanup |
| 80 | + callbacks are executed, dropping the guarded values. |
| 81 | + |
| 82 | +### API |
| 83 | + |
| 84 | +```rust,ignore |
| 85 | +// Create a guard |
| 86 | +let guard = BailoutGuard::new(value); |
| 87 | +
|
| 88 | +// Access the value (implements Deref and DerefMut) |
| 89 | +guard.do_something(); |
| 90 | +let inner: &T = &*guard; |
| 91 | +let inner_mut: &mut T = &mut *guard; |
| 92 | +
|
| 93 | +// Explicitly get references |
| 94 | +let inner: &T = guard.get(); |
| 95 | +let inner_mut: &mut T = guard.get_mut(); |
| 96 | +
|
| 97 | +// Extract the value, cancelling cleanup |
| 98 | +let value: T = guard.into_inner(); |
| 99 | +``` |
| 100 | + |
| 101 | +### Performance Note |
| 102 | + |
| 103 | +`BailoutGuard` incurs a heap allocation. Only use it for values that absolutely |
| 104 | +must be cleaned up, such as: |
| 105 | + |
| 106 | +- File handles |
| 107 | +- Network connections |
| 108 | +- Database connections |
| 109 | +- Locks and mutexes |
| 110 | +- Other system resources |
| 111 | + |
| 112 | +For simple values without cleanup requirements, the overhead isn't worth it. |
| 113 | + |
| 114 | +## Nested Calls |
| 115 | + |
| 116 | +`BailoutGuard` works correctly with nested function calls. Guards at all |
| 117 | +nesting levels are cleaned up when bailout occurs: |
| 118 | + |
| 119 | +```rust,ignore |
| 120 | +#[php_function] |
| 121 | +pub fn outer_function(callback: ZendCallable) { |
| 122 | + let _outer_resource = BailoutGuard::new(Resource::new()); |
| 123 | +
|
| 124 | + inner_function(&callback); |
| 125 | +} |
| 126 | +
|
| 127 | +fn inner_function(callback: &ZendCallable) { |
| 128 | + let _inner_resource = BailoutGuard::new(Resource::new()); |
| 129 | +
|
| 130 | + // If bailout occurs here, both inner and outer resources are cleaned up |
| 131 | + callback.try_call(vec![]); |
| 132 | +} |
| 133 | +``` |
| 134 | + |
| 135 | +## Best Practices |
| 136 | + |
| 137 | +1. **Prefer `try_call`**: For most cases, using `try_call` and handling the |
| 138 | + error result is simpler and doesn't require heap allocation. |
| 139 | + |
| 140 | +2. **Use `BailoutGuard` for critical resources**: Only wrap values that |
| 141 | + absolutely must be cleaned up (connections, locks, etc.). |
| 142 | + |
| 143 | +3. **Don't overuse**: Not every value needs to be wrapped. Simple data |
| 144 | + structures without cleanup requirements don't need `BailoutGuard`. |
| 145 | + |
| 146 | +4. **Combine approaches**: Use `try_call` where possible and `BailoutGuard` |
| 147 | + for critical resources that must be cleaned up regardless. |
0 commit comments