DIAL and LISTEN are two constructors, not two types (transport plane)

The minimal pair, spelled with tcp_transport_t because it is the kind that makes the point plainest: one class, two constructors, and nothing downstream can tell which one built the object it holds.

  • tcp_transport_t(bind_port) listens. Pass 0 and the kernel picks the port; local_port() reports which.

  • tcp_transport_t(peer_host, peer_port) dials, synchronously, inside the constructor.

Past bring-up the role is gone. The example’s exchange() helper takes a transport_t& and drives either end in either direction, which is the whole claim in one signature.

What to notice

  • ok() is the CAME-UP predicate and it is role-specific (#1059): on LISTEN it answers did the bind succeed, on DIAL did the connect succeed. It is answered once, right after construction, and never reverts.

  • link_up() is the other question — is this connection alive now. After a teardown the two diverge: ok() stays true (the link did come up), link_up() goes false. Confusing them is how a dead link gets treated as healthy.

  • The failed bring-up is provoked deterministically. A second LISTEN on the port the first one already holds cannot succeed on any machine. Dialling a port nobody is expected to answer would be a guess about the host rather than a demonstration, and would flake.

  • Direction is not a property of the link. What makes a reply routable is the route the FWD plane grew into the frame’s src, not which end opened the socket — see the src you accumulated is the way home.

  • The ephemeral port is how the two halves rendezvous without a hard-coded number, which is also why every socket example in this tree binds 0: nothing here can collide with whatever else is running on the CI machine.

  • This target needs the TCP transport. It is built only when LIBTRACER_TRANSPORT_TCP is on (the default). Nothing in it is conditional at run time.

Source

  1/*
  2 * SPDX-License-Identifier: Apache-2.0
  3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
  4 */
  5
  6/**
  7 * @file
  8 * @brief ONE CONCEPT — DIAL and LISTEN are not two types, they are two CONSTRUCTORS of the
  9 *        same type; once a link is up the role has left the building and both ends are the
 10 *        same `transport_t`.
 11 *
 12 * This is the minimal pair, spelled with `tcp_transport_t` because it is the kind that
 13 * makes the point plainest — one class, two constructors, and nothing downstream can tell
 14 * which one built the object it holds:
 15 *
 16 *  - `tcp_transport_t(bind_port)` LISTENS. Pass `0` and the kernel picks the port;
 17 *    `local_port()` reports which, which is how a dialer in the same process (a test, a
 18 *    demo, a supervisor that spawns both halves) finds it without a hard-coded number.
 19 *  - `tcp_transport_t(peer_host, peer_port)` DIALS, synchronously, in the constructor.
 20 *
 21 * Two properties follow from "the role is the constructor", and both are load-bearing:
 22 *
 23 *  1. **`ok()` is the CAME-UP predicate, and it is role-specific** (#1059): on LISTEN it
 24 *     answers "did the bind succeed", on DIAL "did the connect succeed". It is answered
 25 *     once, right after construction, and never reverts. `link_up()` is the different
 26 *     question — is the connection alive RIGHT NOW — and after a teardown the two diverge:
 27 *     `ok()` stays true (the link DID come up), `link_up()` goes false.
 28 *  2. **Direction is not a property of the link.** The route the FWD plane grows into a
 29 *     frame's `src` is what makes a reply routable, not which end opened the socket, so
 30 *     both directions are demonstrated here over the one connection.
 31 *
 32 * The failed bring-up is provoked deterministically — a second LISTEN on a port the first
 33 * one already holds — rather than by dialling a port nobody is expected to answer, which
 34 * would be a guess about the machine rather than a demonstration.
 35 *
 36 * Needs the TCP transport (`LIBTRACER_TRANSPORT_TCP`, on by default). Runs under ctest as
 37 * `example_net_dial_and_listen`; returns non-zero on any failed check.
 38 */
 39
 40#include <chrono>
 41#include <condition_variable>
 42#include <cstddef>
 43#include <cstdint>
 44#include <cstdio>
 45#include <mutex>
 46#include <span>
 47#include <vector>
 48
 49#include "libtracer/transport.hpp"
 50#include "libtracer/transport_tcp.hpp"
 51
 52namespace {
 53
 54using namespace std::chrono_literals;
 55using tr::net::tcp_transport_t;
 56
 57/** @brief Report expectation @p what and record a failure on @p ok. */
 58void check(bool& ok, bool cond, const char* what) {
 59    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 60    ok = ok && cond;
 61}
 62
 63/** @brief A thread-safe borrowed-span sink: the recv thread pushes, `main` waits. */
 64class sink_t {
 65   public:
 66    /** @brief The receiver callback — copies the span, which dies when it returns. */
 67    void operator()(std::span<const std::byte> frame) {
 68        {
 69            const std::lock_guard lock(m_);
 70            frames_.emplace_back(frame.begin(), frame.end());
 71        }
 72        cv_.notify_all();
 73    }
 74
 75    /** @brief Wait until at least @p n frames have landed, or @p budget expires. */
 76    [[nodiscard]] bool wait_for(std::size_t n, std::chrono::milliseconds budget) {
 77        std::unique_lock lock(m_);
 78        return cv_.wait_for(lock, budget, [&] { return frames_.size() >= n; });
 79    }
 80
 81    /** @brief Frame @p i, by value. */
 82    [[nodiscard]] std::vector<std::byte> at(std::size_t i) const {
 83        const std::lock_guard lock(m_);
 84        return frames_.at(i);
 85    }
 86
 87   private:
 88    mutable std::mutex m_;
 89    std::condition_variable cv_;
 90    std::vector<std::vector<std::byte>> frames_;
 91};
 92
 93/** @brief @p n bytes counting up from @p seed — a stand-in for an encoded frame. */
 94std::vector<std::byte> frame_of(std::size_t n, unsigned seed) {
 95    std::vector<std::byte> f(n);
 96    for (std::size_t i = 0; i < n; ++i) f[i] = static_cast<std::byte>(seed + i);
 97    return f;
 98}
 99
100/**
101 * @brief Everything past bring-up, written against the SEAM and not against either role.
102 *
103 * The whole point of the example in one signature: this function cannot tell which
104 * constructor made either argument, and does not need to.
105 */
106void exchange(bool& ok, tr::net::transport_t& from, sink_t& at_far_end, std::size_t nth,
107              unsigned seed, const char* what) {
108    const auto f = frame_of(6, seed);
109    from.send(f);
110    check(ok, at_far_end.wait_for(nth, 2s), what);
111    check(ok, at_far_end.at(nth - 1) == f, "  …byte-identical");
112}
113
114}  // namespace
115
116int main() {
117    bool ok = true;
118
119    // LISTEN. Port 0 asks the kernel for an ephemeral one; local_port() reports the answer.
120    sink_t at_listener;
121    tcp_transport_t listener(std::uint16_t{0});
122    listener.set_receiver(at_listener);
123    check(ok, listener.ok(), "LISTEN came up — the bind succeeded");
124    const std::uint16_t port = listener.local_port();
125    check(ok, port != 0, "local_port() resolved the ephemeral 0 to a real port");
126
127    // DIAL. The connect runs inside the constructor, so ok() answers for it on return.
128    sink_t at_dialer;
129    tcp_transport_t dialer("127.0.0.1", port);
130    dialer.set_receiver(at_dialer);
131    check(ok, dialer.ok(), "DIAL came up — the connect succeeded");
132
133    // Past bring-up neither end is privileged: the same call, both ways, through a reference
134    // that has forgotten which constructor ran.
135    std::printf("both directions over the one connection:\n");
136    exchange(ok, dialer, at_listener, 1, 0x10, "dialer -> listener");
137    exchange(ok, listener, at_dialer, 1, 0x20, "listener -> dialer");
138    exchange(ok, dialer, at_listener, 2, 0x30, "dialer -> listener, again");
139
140    check(ok, listener.link_up() && dialer.link_up(), "both ends report the link live");
141
142    // A bring-up that FAILS, provoked deterministically: the port is already held by the
143    // listener above, so the second bind cannot succeed on any machine.
144    std::printf("a refused bring-up:\n");
145    tcp_transport_t taken(port);
146    check(ok, !taken.ok(), "a second LISTEN on a held port did NOT come up");
147    check(ok, !taken.link_up(), "…and reports no live link either — nothing was spawned");
148
149    // ok() never reverts, which is what makes it a different question from link_up(). The
150    // listener is still up here, so this only states the invariant the two accessors carry;
151    // the divergence itself belongs to the teardown paths the transport tests cover.
152    check(ok, listener.ok() && dialer.ok(), "ok() is the came-up fact, answered once");
153
154    std::printf("one type, two constructors: listener on port %u, %s\n",
155                static_cast<unsigned>(port), ok ? "3 frames exchanged" : "FAILED");
156    return ok ? 0 : 1;
157}

See also: transport module · connection config · transports are vertices · the stream framing this kind adds · the multi-peer listener.