Unsubscribe, and when the context dies (L4 graph)¶
unsubscribe(sub) is the host-SDK counterpart of the wire :subscribers[N] clear: it
deactivates the slot, unwinds the subtree-listener bookkeeping, and leaves the (index-stable)
shell for a later subscribe to reuse. Retirement takes effect at once. What the
one-argument form structurally cannot say is when the subscriber’s {fn, ctx} pair
stopped being reachable — so the two-argument form takes a release hook and the library
signals the caller
(ADR-0080).
What to notice¶
The direction is inverted on purpose. The embedder never polls in-flight state and never waits: it registers a
subscriber_release_fn_t, and libtracer calls it exactly once, on the caller’s own thread, outside every graph lock. A contract of the form “the callback may still fire until you call X” is what ADR-0080 rejects.Called from outside a delivery, the hook runs inline. Both shipped policies —
reclaim_local_t(the default) andreclaim_strict_t— release beforeunsubscribe()returns in this case, so the caller may free its context on that return. The other case, unsubscribing from inside a delivery, is its own example.A no-op retire owes no signal. A default-constructed or already-cleared handle answers
NOT_FOUNDand runs no hook — the example resets its flag and re-checks, so “the hook did not run” is asserted rather than assumed.This applies to the callback form. A
subscribe(src, target)edge is a wire:subscribers[]field-write and is removed through the wire clear — the empty-STATUSsentinel written at:subscribers[N].In-flight remote deliveries are not recalled. Over a transport, TLVs dispatched before the clear but not yet consumed still arrive; a subscriber may see a few more after its unsubscribe returns. That is a property of the wire path, not of the in-process retire this example shows.
Source¶
1/*
2 * SPDX-License-Identifier: Apache-2.0
3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
4 */
5
6/**
7 * @file
8 * @brief Unsubscribe, and be TOLD when the subscriber's context is dead (ADR-0080).
9 *
10 * `unsubscribe(sub)` retires the edge: the next fan-out snapshot skips the slot. What it
11 * cannot say on its own is when the `{fn, ctx}` pair stopped being reachable, so the
12 * two-argument overload takes a @ref tr::graph::subscriber_release_fn_t and the LIBRARY
13 * signals the caller — no polling, no waiting. Called from OUTSIDE a delivery (the case
14 * here, and the ordinary one) every shipped policy runs the hook inline, before
15 * `unsubscribe()` returns, so the caller may free its context on that return.
16 *
17 * Runs under ctest as `example_sub_unsubscribe`; it self-checks and returns non-zero on
18 * any mismatch.
19 */
20
21#include <cstddef>
22#include <cstdint>
23#include <cstdio>
24
25#include "libtracer/tracer.hpp"
26
27namespace {
28
29using tr::graph::path_t;
30using tr::graph::role_t;
31using tr::graph::status_t;
32
33/** @brief The subscriber's own state — the `ctx` the edge carries back on every delivery. */
34struct sink_t {
35 int seen = 0; /**< @brief Deliveries observed. */
36 bool released = false; /**< @brief Set by the release hook, exactly once. */
37};
38
39/** @brief The per-delivery sink (`subscriber_fn_t`): a plain function pointer, no erasure. */
40void on_delivery(void* ctx, const tr::view::rope_t&) { ++static_cast<sink_t*>(ctx)->seen; }
41
42/** @brief The release hook — libtracer calls it once, at the policy's grace point. */
43void on_release(void* ctx) { static_cast<sink_t*>(ctx)->released = true; }
44
45/** @brief A one-byte VALUE view over @p b (one heap segment). */
46tr::view::view_t value_byte(std::uint8_t b) {
47 tr::view::segment_ptr_t seg = tr::view::heap_alloc(1);
48 seg->bytes[0] = std::byte{b};
49 return tr::view::view_t::over(std::move(seg));
50}
51
52/** @brief Record a failed expectation on @p ok and report it. */
53void check(bool& ok, bool cond, const char* what) {
54 if (!cond) {
55 std::printf(" [FAIL] %s\n", what);
56 ok = false;
57 }
58}
59
60} // namespace
61
62int main() {
63 tr::graph::graph_t g;
64 const tr::graph::vertex_handle_t src =
65 g.register_vertex(path_t("/sensor/temp"), role_t::STORED_VALUE);
66
67 sink_t sink;
68 const auto sub = g.subscribe(path_t("/sensor/temp"), &on_delivery, &sink);
69 (void)g.write(src, value_byte(0x01));
70
71 const auto gone = g.unsubscribe(*sub, &on_release);
72 const bool released_on_return = sink.released;
73 (void)g.write(src, value_byte(0x02)); // after the retire: reaches nobody
74
75 sink.released = false; // a second retire owes no second signal
76 const auto again = g.unsubscribe(*sub, &on_release);
77 std::printf("seen=%d released_on_return=%d, second unsubscribe -> %s (hook ran: %d)\n",
78 sink.seen, static_cast<int>(released_on_return),
79 again.has_value() ? "ok" : tr::graph::to_string(again.error()),
80 static_cast<int>(sink.released));
81
82 bool ok = true;
83 check(ok, sink.seen == 1,
84 "the write before the unsubscribe was delivered, the one after was not");
85 check(ok, gone.has_value() && released_on_return,
86 "the hook ran inline, before unsubscribe returned");
87 check(ok, !again.has_value() && again.error() == status_t::NOT_FOUND && !sink.released,
88 "an already-cleared handle answers NOT_FOUND — and runs no hook");
89 std::printf("RESULT %s\n", ok ? "ok" : "FAILED");
90 return ok ? 0 : 1;
91}
See also: reclamation policy · graph module · communication flows.