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) and reclaim_strict_t — release before unsubscribe() 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_FOUND and 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-STATUS sentinel 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.