Observer in Rust: Events and Shutdown¶
An Observer design needs more than a callback list: define who owns subscribers, when removal takes effect and what shutdown means. Rust closures and traits can represent notification behaviour, but they do not choose those policies for you. This lesson tests a synchronous callback design against a matching C++ implementation.
Requirements: synchronous Observer delivery with explicit lifetime¶
Publish signed integer readings, including zero and negatives, to callbacks in registration order. Each callback registered at the beginning of a successful publish runs once for that event. A callback may return Remove to unsubscribe itself after receiving that event; later subscribers still receive it. Explicit unsubscribe before publishing prevents delivery. Removing an already removed ID returns false.
The bus owns its callback objects. IDs are scoped to the bus that issued them, are never reused there, and must not be passed to another bus. Exhaustion rejects registration rather than wrapping an ID. These local IDs are not globally authenticated handles; cross-container identity is a separate design requirement in the next lesson.
Close is explicit and idempotent: first close releases all stored callbacks, later close reports false, and publishing or registering after close returns Closed. There is no background work, queue or final shutdown event. Callbacks must return normally and must not re-enter or mutate the bus. Exception/panic recovery, asynchronous delivery and concurrent publishers are outside this fixture's contract.
C++ Observer using owned callables¶
Save as observer.cpp. The shared log lets the program inspect delivery after a callback is removed; it is not the subscription itself.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | |
Both programs produce:
std::function owns a callable wrapper, not every object a callable refers to. Capturing a reference still requires the referenced object to outlive its use. This fixture captures shared ownership of the log rather than a dangling reference. A virtual Observer interface is another option for named subscriber behaviour; it would still need the same lifetime and removal policies.
An empty std::function is representable, so the C++ boundary rejects it with Empty. Rust's generic FnMut parameter requires an actual callable; it has no corresponding empty-wrapper value in this API. This is a difference in representable input, not a different delivery policy.
Removal is recorded during the C++ traversal and applied afterward, so erasing an entry does not invalidate the active traversal. Re-entrant registration or unsubscribe is prohibited, including through a captured pointer to the bus. The type system does not enforce that C++ restriction. The assertions do not invoke prohibited or undefined behaviour.
Rust Observer using boxed FnMut callbacks¶
Replace src/main.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | |
FnMut permits stateful, repeated calls. Box owns each closure; its captured data follows that closure's ownership. The 'static bound excludes captures borrowing short-lived local data, but does not mean the closure lives forever. Removed closures are released normally. Capturing an Rc clone keeps the log alive while preserving an external view.
Rc is single-threaded shared ownership, not thread-safe event delivery. The log's RefCell checks borrows at runtime; holding a conflicting log borrow while publishing can panic. The example takes only short borrows and releases them before the next operation.
retain_mut visits entries once in their original order and retains selected entries. Removing the current subscription therefore does not skip the next one. Unlike C++'s deferred erase, a removed Rust callback can release its captured state during this traversal; this example promises delivery order and removal before the next publish, not identical destructor timing between languages. Captured destructors must not perform bus re-entry.
Dropping a numeric subscription ID does not unsubscribe. The bus still owns the callback. Conversely, dropping the bus normally releases callbacks, but does not run close as an application protocol, send a final event, flush work or guarantee execution during process termination. See ownership and RAII for those boundaries.
Changed requirement: queues, borrowed observers and failure policy¶
A borrowed observer list can avoid owning subscriber objects, but then lifetimes restrict how long the bus may retain them. Rc/Weak or shared_ptr/weak_ptr can instead model externally owned observers; define what happens when the observer expires. Strong references in both directions can create a cycle, so shared ownership alone is not an unsubscribe strategy.
If notifications move to a queue or another thread, event ownership, delivery acknowledgement, bounded capacity, slow subscribers and termination become new requirements. Rust's standard mpsc channels are multiple-producer, single-consumer channels, not automatic broadcast to all observers. Fan-out requires explicit per-subscriber routing or another abstraction. Successfully enqueueing a value is not proof that a subscriber processed it.
Define whether shutdown drains or discards queued values, how producers stop, what happens while sender clones remain, and whether a worker must be joined. Adding a channel does not implement those choices. This synchronous fixture has nothing to drain and does not claim asynchronous or exactly-once durable delivery.
If callbacks may fail, decide whether later subscribers still run and how failures are returned. The current Keep/Remove result is a subscription action, not an error or acknowledgement. A panic or exception halfway through this example is outside its successful-publish guarantee; do not promise transactional rollback or resume after failure without designing and testing it.
Intentional failures: capture contracts differ¶
This independent Rust example borrows a local String in a closure required to be 'static:
The ownership repair is a move closure: the String then belongs to the callable. A borrowed registration API would be a different lifetime contract, not permission to outlive the borrowed label.
C++20 std::function instead requires a copy-constructible target. This independent move-only capture cannot be stored in it:
An intentionally shared capture is one repair if shared ownership fits the requirements. A move-only callable wrapper is another design, but is not this C++20 std::function example. Rust's boxed FnMut does not require Clone, and the DropProbe test stores non-Clone owned state. Do not add sharing solely to silence a compiler error without deciding who should own the subscriber state.
Decision criteria and exercises¶
| Requirement | Candidate | Policy still needed |
|---|---|---|
| Synchronous local delivery | Owned callbacks or named observer trait/interface | Order, re-entry, failure and removal timing |
| Externally owned subscribers | Borrowed references or weak ownership | Expiry and lifetime rules |
| Subscription scoped to an owner | Explicit unsubscribe or designed subscription guard | Whether dropping a token removes it, and how it reaches the bus safely |
| Deferred or cross-thread processing | Owned event queues and appropriate thread-safe bounds | Backpressure, fan-out, acknowledgements and shutdown |
- Add a callback with an internal call count that removes itself on the second event. Verify order and absence on the third event.
- Change the Rust removal predicate to always keep entries. Retain the self-removal test: this compiles but must fail the delivery contract.
- Repair the Rust capture with move and the C++ target with an explicitly shared owner. Confirm the captured value remains valid when called.
- Compare explicit close with ordinary destruction. Which application actions occur in neither example, and which resources are simply released?
- Before replacing the list with a channel, write the drain/discard, slow-subscriber and acknowledgement policies. Explain why one mpsc receiver is not a broadcast list.
Verification and next step¶
The exact examples were verified on Raspberry Pi 4B with rustc/cargo 1.99.0, GCC 14.2.0 and kernel 6.18.50+rpt-rpi-v8. Rust passed six tests in debug and release, exact-source formatting and both output checks. The second-event removal variant passed seven tests in both profiles, and the owned-capture repair passed. The borrowed 'static capture failed with E0373; deliberately retaining removed subscribers compiled but failed the delivery test. C++20 passed output/assertions and second-event/shared-capture repairs at -O0 and -O2, with assertions enabled; std::function rejected the move-only target. Normal destruction, explicit close, ownership release and discarded IDs were checked without invoking prohibited re-entry. These tests are not timing measurements or a proof for all callback failures and thread schedules.
Next: ownership, IDs and ECS design, identity and processing boundaries based on requirements rather than benchmarks.
Previous: Visitor and extension boundaries · Course overview