Skip to main content

melin_server_runtime/
server.rs

1//! Server orchestrator — binds the accept loop, pipeline threads, and reader.
2//!
3//! On startup:
4//! 1. Recovers or creates the `JournaledApp<A>`.
5//! 2. Decomposes it into `(A, W)` via `into_parts()`, where `W` is the journal writer.
6//! 3. Builds the disruptor pipeline (input ring + output ring).
7//! 4. Spawns 3-5 OS threads: journal, matching, response, [repl-accept], [event-publisher].
8//! 5. Runs the accept loop, registering connections with the io_uring reader.
9//!
10//! Fully synchronous — no async runtime needed. Reader threads use io_uring
11//! with multishot RECV to multiplex connections, eliminating thread
12//! oversubscription. The response thread writes via io_uring SEND.
13
14use std::net::{IpAddr, Ipv4Addr, SocketAddr};
15use std::os::unix::io::AsRawFd;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
19
20use tracing::{debug, error, info, warn};
21
22use melin_journal::BufferedWriter;
23use melin_journal::JournalError;
24use melin_journal::JournalWrite;
25use melin_transport_core::journaled_app::JournaledApp;
26use melin_transport_core::pipeline::{
27    InputSlot, OutputSlot as GenericOutputSlot, Pipeline as GenericPipeline,
28    build_pipeline_with_replication,
29};
30/// Internal alias for the disruptor-built pipeline, used only by
31/// destructuring `let Pipeline { … }` patterns inside the boot path.
32/// Not part of the public API — callers reach the underlying type
33/// through `melin_transport_core::pipeline`.
34type Pipeline<A> = GenericPipeline<A>;
35
36use crate::StartupEvents;
37use crate::reader::RequestDecoderArc;
38use crate::response::ResponseEncoderArc;
39use melin_app::Application;
40use melin_app::auth::AuthorizedKeys;
41use melin_app::auth::Permission;
42use melin_app::decoder::RequestDecoder;
43use melin_app::encoder::ResponseEncoder;
44use melin_pipeline::ring::Consumer;
45use melin_pipeline::wait::WaitStrategy;
46
47use crate::layout::{DEFAULT_CORES, parse_cores};
48/// The layout types live in [`crate::layout`]; re-exported here because
49/// `ServerConfig::cores` is one of them and callers reach the config
50/// through this module.
51pub use crate::layout::{PipelineCores, Placement, ReaderThread};
52
53/// How the orchestrator (main) thread waits on pipeline threads during
54/// startup — the seed drain and the promotion epoch bump. It is unpinned
55/// and on no latency path, so it yields whatever the layout says: a
56/// spinner without a core of its own is the co-location bug with the
57/// victim chosen by the scheduler.
58const ORCHESTRATOR_WAIT: WaitStrategy = WaitStrategy::SpinThenYield;
59
60/// Output-slot sugar parameterised on the application — saves spelling
61/// `<A::Report, A::QueryResponse>` at every pipeline-facing signature
62/// that doesn't already destructure them.
63type OutputSlot<A> =
64    GenericOutputSlot<<A as Application>::Report, <A as Application>::QueryResponse>;
65
66/// Body of the event-publisher thread: a free function with this exact
67/// signature (an application with an output feed supplies its
68/// publisher; one without supplies nothing). Passing it as a function
69/// pointer keeps the runtime decoupled from the application — no
70/// application reference inside server.rs — without paying for a
71/// boxed closure.
72///
73/// Threaded into [`run`] (and `run_dpdk` under `feature = "dpdk"`) as `Option<EventPublisherFn>`:
74/// `None` disables the publisher unconditionally, `Some(_)` wires it up
75/// when `--event-bind` is also set.
76pub type EventPublisherFn<A> = fn(
77    consumer: Consumer<OutputSlot<A>>,
78    bind_addr: SocketAddr,
79    authorized_keys: Arc<AuthorizedKeys>,
80    shutdown: &AtomicBool,
81    wait: WaitStrategy,
82);
83
84use melin_wire_protocol::blocking::BlockingFrameWriter;
85use melin_wire_protocol::control::ConnectionId;
86use melin_wire_protocol::transport::BlockingTransportListener;
87
88/// Default replica pipeline depth (pending ack queue capacity).
89///
90/// Only bounds persisted-ack *granularity*, never the receive rate: the
91/// queue merges instead of blocking when it fills. 256 entries × 16 B is
92/// 4 KiB, so the depth is chosen to keep granularity through an fsync
93/// hiccup rather than to save memory.
94const DEFAULT_REPLICATION_PIPELINE_DEPTH: usize = 256;
95
96/// CLI spelling of [`melin_journal::StagingMode`].
97///
98/// A separate enum rather than a `clap::ValueEnum` derive on the journal
99/// type: `melin-journal` has no clap dependency and should not acquire
100/// one for a flag: the crate is a library first, and its callers are not
101/// all command-line servers.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
103pub enum JournalStagingMode {
104    /// Pre-write every staged segment so appends generate no
105    /// extent-conversion metadata. Costs one extra pass over each
106    /// segment in device bandwidth.
107    #[default]
108    ZeroFill,
109    /// Allocate staged extents without materialising them. No staging
110    /// bandwidth; appends convert extents and `fdatasync` periodically
111    /// forces the filesystem log.
112    Allocate,
113}
114
115impl From<JournalStagingMode> for melin_journal::StagingMode {
116    fn from(m: JournalStagingMode) -> Self {
117        match m {
118            JournalStagingMode::ZeroFill => melin_journal::StagingMode::ZeroFill,
119            JournalStagingMode::Allocate => melin_journal::StagingMode::Allocate,
120        }
121    }
122}
123
124/// Server configuration, parsed from CLI arguments via clap.
125#[derive(clap::Parser)]
126#[command(
127    name = "melin-server",
128    about = "A node of the Melin replicated sequencer"
129)]
130pub struct ServerConfig {
131    /// Address to bind the TCP listener.
132    #[arg(long, default_value = "127.0.0.1:9876")]
133    pub bind: SocketAddr,
134    /// Path to the journal file for durable event sourcing.
135    #[arg(long, default_value = "melin.journal")]
136    pub journal: PathBuf,
137    /// Path to a snapshot file for faster recovery.
138    #[arg(long)]
139    pub snapshot: Option<PathBuf>,
140    /// Where each pipeline thread runs, as comma-separated `thread=core`
141    /// entries in any order. Threads: journal-seq, matching, response, reader,
142    /// event-publisher, shadow, repl-handler-0, repl-handler-1, journal-prep,
143    /// journal-disk. Every thread must be named: `0` leaves one unpinned,
144    /// and `none` unpins every thread. The node warns at boot when
145    /// journal-seq, matching, response, reader or journal-disk has no core:
146    /// every request passes through them. Core 0 is reserved for OS/IRQ
147    /// handling.
148    /// reader pins the io_uring reader (TCP) or DPDK poll thread.
149    /// event-publisher applies when `--event-bind` is set, shadow when
150    /// `--snapshot-interval-ms` > 0. repl-handler-0/1 are for the
151    /// per-replica TCP handler threads. journal-disk pins the thread that
152    /// writes and syncs the journal; give it a core on the same CCD as
153    /// journal-seq, since the two exchange a cache line per batch.
154    ///
155    /// Each core may carry a suffix saying how that thread waits: `7`
156    /// (or `7s`) busy-spins and needs the core to itself, `7y` spins
157    /// briefly then yields and may share the core with other `y`
158    /// threads. `0` (unpinned) always yields. Two threads on one core
159    /// where either busy-spins is refused at startup — they would starve
160    /// each other. journal-prep never busy-waits and takes no suffix.
161    #[arg(long, default_value = DEFAULT_CORES, value_parser = parse_cores)]
162    pub cores: PipelineCores,
163    /// Group commit coalescing delay in microseconds. Keep at 0 for TCP.
164    #[arg(long, default_value_t = 0)]
165    pub group_commit_us: u64,
166    /// Heartbeat interval in seconds. The server sends a heartbeat to idle
167    /// connections after this many seconds of silence. Set to 0 to disable.
168    #[arg(long, default_value_t = 10)]
169    pub heartbeat_interval_secs: u64,
170    /// Connection timeout in seconds. The server disconnects clients that
171    /// have not sent any data within this window. Set to 0 to disable.
172    #[arg(long, default_value_t = 30)]
173    pub connection_timeout_secs: u64,
174    /// Maximum number of concurrent authenticated connections. New
175    /// connections are rejected (closed immediately) when this limit is
176    /// reached. 0 means unlimited. Prevents fd/memory exhaustion (SEC-02).
177    #[arg(long, default_value_t = 1024)]
178    pub max_connections: u64,
179    /// Path to the authorized keys file for Ed25519 challenge-response
180    /// authentication. Every connection must authenticate before sending
181    /// requests.
182    /// Required for primary mode; ignored in replica mode (--replica-of).
183    /// See `AuthorizedKeys` for file format.
184    #[arg(long, default_value = "authorized_keys")]
185    pub authorized_keys: PathBuf,
186    /// Maximum journal size in MiB before automatic rotation at startup.
187    /// When the journal exceeds this threshold, the server saves a snapshot
188    /// and starts a fresh journal. Set to 0 to disable. Default: 256 MiB.
189    #[arg(long, default_value_t = 256)]
190    pub max_journal_mib: u64,
191    /// How the background preparer materialises the next journal
192    /// segment's extents.
193    ///
194    /// - `zero-fill` (default) physically pre-writes the segment, so
195    ///   appends carry no extent-conversion metadata and each flush's
196    ///   `fdatasync` never forces the filesystem log. It costs one extra
197    ///   pass over every segment in device bandwidth.
198    /// - `allocate` reserves the extents and stops. Staging becomes
199    ///   free, and the periodic log force returns to the flush path.
200    ///
201    /// Keep the default on local NVMe, where sequential bandwidth is
202    /// cheap and the log forces are what hurts. Consider `allocate` on
203    /// network-attached storage (EBS and similar), where staging
204    /// bandwidth is metered and drawn from the same budget as the hot
205    /// path — measure both on the volume in question rather than
206    /// assuming, since which one wins is a property of the device.
207    #[arg(long, value_enum, default_value_t = JournalStagingMode::ZeroFill)]
208    pub journal_staging_mode: JournalStagingMode,
209
210    /// Address to listen for replica connections (enables synchronous replication).
211    /// Mutually exclusive with `--standalone` and `--replica-of`.
212    #[arg(long)]
213    pub replication_bind: Option<std::net::SocketAddr>,
214
215    /// Disable replication (dev/test mode). The replica quorum cursor stays
216    /// at its no-replica sentinel (health reports zero replication lag) and
217    /// the ack policy evaluates against the primary's journal alone.
218    /// Mutually exclusive with `--replication-bind` and `--replica-of`.
219    #[arg(long, default_value_t = false)]
220    pub standalone: bool,
221
222    /// Run as a replica connected to the given primary address.
223    /// In replica mode, the server does not accept client connections.
224    /// Mutually exclusive with `--replication-bind` and `--standalone`.
225    #[arg(long)]
226    pub replica_of: Option<std::net::SocketAddr>,
227
228    /// Path to the Ed25519 private key for replication authentication.
229    /// Required in replica mode (`--replica-of`). The corresponding
230    /// public key must be listed in the primary's authorized_keys file
231    /// with permission `replication`.
232    #[arg(long)]
233    pub replication_key: Option<std::path::PathBuf>,
234
235    /// Maximum number of replication ring batches to coalesce into a
236    /// single TCP write+flush. Higher values reduce syscall overhead
237    /// but increase per-write latency. Default: 128.
238    #[arg(long, default_value_t = 128)]
239    pub replication_batch_size: usize,
240
241    /// Maximum events per journal fsync batch. Smaller values reduce
242    /// tail latency (less work per sync), larger values improve throughput
243    /// (fewer fsyncs). Default: 4096.
244    #[arg(long, default_value_t = 4096)]
245    pub max_journal_batch: usize,
246
247    /// Replication heartbeat interval in seconds. The primary sends a
248    /// heartbeat to the replica after this many seconds of idle. Used
249    /// for disconnect detection. Default: 5.
250    #[arg(long, default_value_t = 5)]
251    pub replication_heartbeat_secs: u64,
252
253    /// Number of receive batches the replica tracks individually while
254    /// they await local journal fsync. Must be a power of two. Beyond
255    /// this depth the receiver keeps receiving and coalesces pending
256    /// acks (a later ack, never an early one) — it never applies
257    /// backpressure to the primary. Default: 256.
258    #[arg(long, default_value_t = DEFAULT_REPLICATION_PIPELINE_DEPTH)]
259    pub replication_pipeline_depth: usize,
260
261    /// Number of slots in each replication ring buffer. Must be a power
262    /// of two. Each slot holds up to 512 KiB. More slots = more buffering
263    /// before eviction. Default: 256 (128 MiB per ring, 256 MiB dual-repl).
264    #[arg(long, default_value_t = 256)]
265    pub replication_ring_size: usize,
266
267    /// Ack policy: which copies of an event must exist before its
268    /// response is released. One of:
269    ///
270    /// - `disk`               `persisted>=1`. One fsynced copy, on
271    ///   whichever node's disk confirms first; required with
272    ///   `--standalone`. Dev/staging deployments.
273    /// - `ram`                `in_memory>=2`. Two nodes hold the event
274    ///   in memory before the ack; every fsync trails off the ack path
275    ///   (the journal still syncs every batch). Lowest ack latency;
276    ///   survives any single node failure via failover; loses only the
277    ///   un-fsynced tail on a whole-cluster power loss. For slow-fsync
278    ///   storage (cloud volumes) or RPO-tolerant apps.
279    /// - `disk+ram` (default) `persisted>=1 && in_memory>=2`. One
280    ///   fsynced copy plus a second copy in another node's memory.
281    ///   Single-failure-safe with a brief RAM-only window for the
282    ///   second copy. Typical live deployments. Faster than `two-disks`:
283    ///   an acknowledgement waits for the second node to receive the
284    ///   event, not to fsync it.
285    /// - `two-disks`          `persisted>=2`. Two fsynced copies before
286    ///   the client ack. Zero RAM-only window; the gate stalls when no
287    ///   replica is connected. Compliance-driven deployments.
288    ///
289    /// `--standalone` requires `disk`. Under every other policy the
290    /// gate stalls while no replica is connected — the correct
291    /// behaviour for a serious deployment that has lost its replicas.
292    /// See `docs/replication.md` for the operational menu.
293    #[arg(long, value_enum, default_value_t = crate::ack_policy::AckPolicy::DiskAndRam)]
294    pub ack_policy: crate::ack_policy::AckPolicy,
295
296    // --- DPDK configuration (only used with --features dpdk) ---
297    /// DPDK EAL arguments (space-separated). Example: --dpdk-eal-args="-l 0-7 --huge-dir /dev/hugepages".
298    /// Passed directly to rte_eal_init. Only used when compiled with --features dpdk.
299    /// The `=` is required: the value itself starts with a dash, and requiring
300    /// the joined form keeps a forgotten value from silently swallowing the
301    /// next flag as the EAL string.
302    #[arg(long, default_value = "", require_equals = true)]
303    pub dpdk_eal_args: String,
304
305    /// DPDK port IDs (comma-separated). For LACP bonds, pass both VF ports
306    /// (e.g., "0,1") so traffic arriving on either bond member is received.
307    #[arg(long, default_value = "0", value_delimiter = ',')]
308    pub dpdk_ports: Vec<u16>,
309
310    /// IPv4 address for the DPDK interface (e.g., "10.0.0.1").
311    #[arg(long, default_value = "10.0.0.1")]
312    pub dpdk_ip: String,
313
314    /// IPv4 prefix length for the DPDK interface. Default: 24.
315    #[arg(long, default_value_t = 24)]
316    pub dpdk_prefix_len: u8,
317
318    /// IPv4 gateway for the DPDK interface (optional, needed for cross-subnet traffic).
319    #[arg(long)]
320    pub dpdk_gateway: Option<String>,
321
322    /// Peer IPv4 used for the bifurcated `rte_flow` steering rule. When
323    /// set, the DPDK port opens in isolated mode and only IPv4 packets
324    /// sourced from this address are delivered into DPDK queue 0 —
325    /// everything else stays with the kernel netdev. Required for L3
326    /// setups that share the public NIC with the kernel (SSH, etc.).
327    #[arg(long)]
328    pub dpdk_peer_ip: Option<String>,
329
330    /// Gateway MAC (aa:bb:cc:dd:ee:ff) seeded into smoltcp for the
331    /// `--dpdk-gateway` IP. Required in L3 bifurcated mode because the
332    /// gateway's ARP reply would not match the steering rule and would
333    /// be eaten by the kernel. Source from `ip neigh` on the host.
334    #[arg(long)]
335    pub dpdk_gateway_mac: Option<String>,
336
337    /// MAC (aa:bb:cc:dd:ee:ff) of the primary named by `--replica-of`,
338    /// seeded into smoltcp so the replica's first frame is addressed
339    /// before any ARP. Required on ports that keep a real hardware MAC —
340    /// an mlx5 in bifurcated mode shares the kernel netdev's — where the
341    /// SR-IOV `02:00:<ip>` fallback is wrong and the replica would spin
342    /// on connect with no error. Read it from
343    /// `/sys/class/net/<iface>/address` on the primary, or from
344    /// `DPDK_MAC` in `/etc/melin-dpdk.conf`. Ignored on a primary, which
345    /// learns peer MACs from inbound frames.
346    #[arg(long)]
347    pub dpdk_peer_mac: Option<String>,
348
349    /// MTU for the DPDK interface. Use 9000 for jumbo frames (6x fewer TCP
350    /// segments). Requires switch and PF MTU to be set accordingly.
351    #[arg(long, default_value_t = 1500)]
352    pub dpdk_mtu: usize,
353
354    /// VLAN ID for hardware VLAN strip/insert. Required in dedicated NIC
355    /// mode (dpdk-setup-dedicated.sh) where the kernel doesn't handle VLAN
356    /// tags. Not needed for SR-IOV mode (the PF handles VLAN tagging).
357    #[arg(long)]
358    pub dpdk_vlan: Option<u16>,
359
360    /// Address for the output event publisher. Subscribers connect here
361    /// to receive a real-time stream of the application's reports, as the
362    /// application's publisher encodes them. Ed25519 auth required
363    /// (ReadOnly or above).
364    /// Omit to disable (ring has 1 consumer — identical to before).
365    #[arg(long)]
366    pub event_bind: Option<SocketAddr>,
367
368    /// Address for the health/liveness TCP endpoint. On connect, returns
369    /// a one-line status (`OK <conns> <journal_seq> <repl_lag>\n`) and
370    /// closes. No auth required. Set to empty string to disable.
371    #[arg(long, default_value = "127.0.0.1:9878")]
372    pub health_bind: Option<SocketAddr>,
373
374    /// TCP address for the operator admin endpoint. Authenticated with
375    /// operator keys (Ed25519 challenge-response, same as all other
376    /// admin handshakes). Accepts:
377    ///
378    /// - `PROMOTE\n` — replica → primary leadership transition (replica
379    ///   nodes only; rejected with ERR on a primary).
380    /// - `ROTATE\n` — archive the current journal segment at the next
381    ///   fsync boundary (any node where runtime rotation is enabled).
382    ///
383    /// Unset means no admin endpoint is listening; operators can still
384    /// rely on the size-driven rotation trigger (`--max-journal-mib`)
385    /// without exposing a port.
386    #[arg(long)]
387    pub admin_bind: Option<SocketAddr>,
388
389    /// Interval in milliseconds between automatic shadow snapshots. The
390    /// shadow stage replays journaled events on a cloned `A` and saves a
391    /// consistent snapshot at this cadence — no hot-path stall. Set to 0
392    /// to disable shadow snapshots entirely. Default: 3 000 000 ms (50 min).
393    #[arg(long, default_value_t = 3_000_000)]
394    pub snapshot_interval_ms: u64,
395
396    /// Path for shadow snapshots. Defaults to the journal path with a
397    /// `.snapshot` extension (same as the startup snapshot path).
398    #[arg(long)]
399    pub snapshot_path: Option<PathBuf>,
400
401    /// Cadence in milliseconds for the application's clock tick.
402    /// The ingress thread (io_uring reader or DPDK poll thread) publishes
403    /// a `JournalEvent::Tick { now_ns }` at this interval so the
404    /// application's time-driven work (expiries, timeouts, scheduled
405    /// transitions) fires in deterministic, journaled lockstep. There is
406    /// no separate tick thread on either transport. Set to 0 to disable
407    /// tick generation entirely (useful for benchmarks that don't
408    /// exercise time-driven features).
409    ///
410    /// Defaults to 250 ms. Under load the matching stage advances the
411    /// application's clock at every-event resolution from `slot.timestamp_ns`
412    /// (microsecond precision), so the tick is only the safety net for
413    /// quiet periods. 250 ms keeps time-driven work in quiet periods within
414    /// a quarter-second of their deadline at a cost of ~4 events/sec of
415    /// journal traffic.
416    #[arg(long, default_value_t = 250)]
417    pub tick_interval_ms: u64,
418
419    /// Disable `mlockall(MCL_CURRENT | MCL_FUTURE)` at startup.
420    ///
421    /// By default the server locks all current and future pages into
422    /// RAM to prevent rare multi-millisecond stalls from page faults
423    /// or swap activity on the matching hot path. Locking requires
424    /// `CAP_IPC_LOCK` (or running as root) and `RLIMIT_MEMLOCK` raised
425    /// to a value larger than the process's RSS — the server raises
426    /// the rlimit itself, but only succeeds with the capability.
427    ///
428    /// Use `--no-mlock` for development / containerised runs where
429    /// the privilege isn't available; on a bare-metal production host
430    /// leave it on.
431    #[arg(long, default_value_t = false)]
432    pub no_mlock: bool,
433
434    /// Bind address for the control-plane raft RPC listener. Setting this
435    /// enables the control plane on this node: leader election runs and is
436    /// observable via the `melin_raft_*` gauges. Requires `--raft-node-id`
437    /// and a `--raft-peer` entry for this node; requires
438    /// `--replication-key` (peer links authenticate with the replication
439    /// key, same trust domain as the data plane). Raft always runs on
440    /// kernel TCP, including on DPDK nodes.
441    #[arg(long)]
442    pub raft_bind: Option<SocketAddr>,
443
444    /// This node's control-plane raft id (non-zero, unique per cluster).
445    #[arg(long)]
446    pub raft_node_id: Option<u64>,
447
448    /// A control-plane cluster member as `id@host:port#base64-pubkey`
449    /// (repeatable). Give **every node the same list, including an entry
450    /// for the node itself** — the self entry supplies the dialable
451    /// address peers use to reach it, and identical lists keep the
452    /// first-boot membership consistent. The pubkey pins the peer's
453    /// identity: a connection authenticated with a different key cannot
454    /// speak for that id.
455    #[arg(long)]
456    pub raft_peer: Vec<String>,
457
458    /// Directory for durable raft state (vote, log, membership).
459    /// Defaults to the journal path with a `.raft` extension. Must live
460    /// on the same durability class as the journal — losing it can
461    /// double-grant a vote (see docs/replication.md).
462    #[arg(long)]
463    pub raft_dir: Option<PathBuf>,
464
465    /// Act on control-plane election wins: a replica elected leader
466    /// promotes itself (journaling the election term as its fencing
467    /// epoch), and a serving node self-fences when the raft mesh shows
468    /// it superseded. Off by default — automatic failover is an explicit
469    /// operator policy decision. Requires `--raft-bind` and at least
470    /// three configured voters (a two-node cluster cannot elect after
471    /// losing either node, so automation would be dead weight with real
472    /// misconfiguration risk; see docs/replication.md).
473    #[arg(long, default_value_t = false)]
474    pub raft_auto_promote: bool,
475}
476
477/// Delegates to clap so `#[arg(default_value...)]` is the single source of
478/// truth for every default.  Used by the bench crate for struct-literal
479/// construction with `..ServerConfig::default()`.
480impl Default for ServerConfig {
481    fn default() -> Self {
482        // On the dpdk branch we spell out every field so that dpdk-specific
483        // fields (not known to clap on plain builds) get their defaults.
484        // Main uses `Self::parse_from(["melin-server"])` — the values below
485        // mirror those clap defaults plus the dpdk extras.
486        Self {
487            bind: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9876),
488            journal: PathBuf::from("melin.journal"),
489            snapshot: None,
490            cores: PipelineCores {
491                journal_seq: Placement::spinning(1),
492                matching: Placement::spinning(2),
493                response: Placement::spinning(3),
494                reader: Placement::spinning(4),
495                event_publisher: Placement::spinning(6),
496                shadow: Placement::spinning(7),
497                repl_handler_0: Placement::spinning(8),
498                repl_handler_1: Placement::spinning(9),
499                journal_prep: Placement::yielding(10),
500                journal_disk: Placement::spinning(11),
501            },
502            group_commit_us: 0,
503            heartbeat_interval_secs: 10,
504            connection_timeout_secs: 30,
505            max_connections: 1024,
506            authorized_keys: PathBuf::from("authorized_keys"),
507            max_journal_mib: 256,
508            journal_staging_mode: JournalStagingMode::ZeroFill,
509
510            replication_bind: None,
511            standalone: false,
512            replica_of: None,
513            replication_key: None,
514            replication_batch_size: 128,
515            max_journal_batch: 1024,
516            replication_heartbeat_secs: 5,
517            replication_pipeline_depth: DEFAULT_REPLICATION_PIPELINE_DEPTH,
518            replication_ring_size: 256,
519            ack_policy: crate::ack_policy::AckPolicy::DiskAndRam,
520            dpdk_eal_args: String::new(),
521            dpdk_peer_ip: None,
522            dpdk_gateway_mac: None,
523            dpdk_peer_mac: None,
524            dpdk_ports: vec![0],
525            dpdk_ip: "10.0.0.1".into(),
526            dpdk_prefix_len: 24,
527            dpdk_gateway: None,
528            dpdk_mtu: 1500,
529            dpdk_vlan: None,
530            event_bind: None,
531            health_bind: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9878)),
532            admin_bind: None,
533            snapshot_interval_ms: 3_000_000,
534            snapshot_path: None,
535            tick_interval_ms: 250,
536            no_mlock: false,
537            raft_bind: None,
538            raft_node_id: None,
539            raft_peer: Vec::new(),
540            raft_dir: None,
541            raft_auto_promote: false,
542        }
543    }
544}
545
546impl ServerConfig {
547    /// Group commit delay as a Duration.
548    pub fn group_commit_delay(&self) -> std::time::Duration {
549        std::time::Duration::from_micros(self.group_commit_us)
550    }
551
552    /// Heartbeat interval as a Duration. Returns `None` if disabled (0).
553    pub fn heartbeat_interval(&self) -> Option<std::time::Duration> {
554        if self.heartbeat_interval_secs == 0 {
555            None
556        } else {
557            Some(std::time::Duration::from_secs(self.heartbeat_interval_secs))
558        }
559    }
560
561    /// Connection timeout as a Duration. Returns `None` if disabled (0).
562    pub fn connection_timeout(&self) -> Option<std::time::Duration> {
563        if self.connection_timeout_secs == 0 {
564            None
565        } else {
566            Some(std::time::Duration::from_secs(self.connection_timeout_secs))
567        }
568    }
569
570    /// Snapshot path for the shadow stage. Uses the explicit `--snapshot-path`
571    /// if set, otherwise derives from the journal path with `.snapshot` extension.
572    pub fn shadow_snapshot_path(&self) -> PathBuf {
573        self.snapshot_path
574            .clone()
575            .unwrap_or_else(|| self.journal.with_extension("snapshot"))
576    }
577
578    /// Tick generator cadence as a `Duration`. Returns `None` when the tick
579    /// thread is disabled (`--tick-interval-ms 0`).
580    pub fn tick_interval(&self) -> Option<std::time::Duration> {
581        if self.tick_interval_ms == 0 {
582            None
583        } else {
584            Some(std::time::Duration::from_millis(self.tick_interval_ms))
585        }
586    }
587
588    /// The per-thread layout this node actually runs — see
589    /// [`PipelineCores::resolve`]. `reader` says which thread the reader
590    /// entry pins on this transport, since one of the two cannot yield.
591    ///
592    /// An `Err` is a configuration the node refuses to start with.
593    pub fn resolved_cores(&self, reader: ReaderThread) -> Result<PipelineCores, String> {
594        self.cores.resolve(reader)
595    }
596}
597
598/// Run the server with automatic transport selection.
599///
600/// Installs SIGINT/SIGTERM handlers, optionally locks memory, selects
601/// the transport (DPDK under the `dpdk` feature, kernel TCP otherwise),
602/// and enters the pipeline loop. This is the main entry point for
603/// application binaries.
604///
605/// The application starts from `A::default()` on every node; `startup`
606/// is what the node journals on top of that as it becomes primary — see
607/// [`StartupEvents`]. `sizing` is what this node reserves memory for,
608/// handed to [`Application::prefault`] on every instance the node
609/// builds; it is local to the node and never journaled.
610///
611/// For callers that need a pre-bound listener or an externally
612/// controlled shutdown flag (e.g. benchmarks), use
613/// [`run_with_listener`] instead.
614pub fn run<A>(
615    config: ServerConfig,
616    startup: StartupEvents<A::Event>,
617    sizing: A::Sizing,
618    decoder: impl RequestDecoder<Event = A::Event> + 'static,
619    encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
620    event_publisher: Option<EventPublisherFn<A>>,
621) -> Result<(), Box<dyn std::error::Error>>
622where
623    A: Application + Send + 'static,
624    A::Event: Send + Sync + 'static,
625    A::Report: Send + 'static,
626    A::QueryResponse: Send + 'static,
627{
628    // Before anything pins a thread or initialises DPDK, whose EAL can
629    // narrow this thread to one lcore: the CPU set captured here is what
630    // every unpinned thread runs on.
631    melin_app::affinity::capture_home_mask();
632    let shutdown = Arc::new(AtomicBool::new(false));
633    crate::process::install_shutdown_handler(&shutdown);
634
635    if !config.no_mlock {
636        crate::process::try_lock_memory();
637    }
638
639    #[cfg(feature = "dpdk")]
640    {
641        run_dpdk::<A>(
642            config,
643            startup,
644            sizing,
645            Arc::new(decoder),
646            Arc::new(encoder),
647            event_publisher,
648            shutdown,
649        )
650    }
651
652    #[cfg(not(feature = "dpdk"))]
653    {
654        let listener = melin_wire_protocol::tcp::BlockingTcpListener::bind(config.bind)?;
655        run_tcp::<A, _>(
656            listener,
657            config,
658            startup,
659            sizing,
660            Arc::new(decoder),
661            Arc::new(encoder),
662            event_publisher,
663            shutdown,
664        )
665    }
666}
667
668/// Run the server with a caller-supplied listener.
669///
670/// Use this when the listener must be pre-bound before the server
671/// starts (e.g. benchmarks that need the kernel backlog to queue
672/// connections immediately).
673///
674/// Set `shutdown` to `true` to trigger a clean shutdown of all
675/// pipeline threads.
676pub fn run_with_listener<A>(
677    listener: impl BlockingTransportListener,
678    config: ServerConfig,
679    startup: StartupEvents<A::Event>,
680    sizing: A::Sizing,
681    decoder: impl RequestDecoder<Event = A::Event> + 'static,
682    encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
683    event_publisher: Option<EventPublisherFn<A>>,
684    shutdown: Arc<AtomicBool>,
685) -> Result<(), Box<dyn std::error::Error>>
686where
687    A: Application + Send + 'static,
688    A::Event: Send + Sync + 'static,
689    A::Report: Send + 'static,
690    A::QueryResponse: Send + 'static,
691{
692    // As in `run`: the CPU set unpinned threads run on, captured before
693    // anything pins a thread.
694    melin_app::affinity::capture_home_mask();
695    run_tcp::<A, _>(
696        listener,
697        config,
698        startup,
699        sizing,
700        Arc::new(decoder),
701        Arc::new(encoder),
702        event_publisher,
703        shutdown,
704    )
705}
706
707fn run_tcp<A, L>(
708    listener: L,
709    config: ServerConfig,
710    startup: StartupEvents<A::Event>,
711    sizing: A::Sizing,
712    decoder: RequestDecoderArc<A>,
713    encoder: ResponseEncoderArc<A>,
714    event_publisher: Option<EventPublisherFn<A>>,
715    shutdown: Arc<AtomicBool>,
716) -> Result<(), Box<dyn std::error::Error>>
717where
718    A: Application + Send + 'static,
719    A::Event: Send + Sync + 'static,
720    A::Report: Send + 'static,
721    A::QueryResponse: Send + 'static,
722    L: BlockingTransportListener,
723{
724    run_impl::<A, L>(
725        listener,
726        config,
727        startup,
728        sizing,
729        decoder,
730        encoder,
731        event_publisher,
732        shutdown,
733    )
734}
735
736/// Announce at boot that this binary was built without the
737/// tamper-evident journal chain.
738///
739/// A chain-less build is otherwise silent: the journal still writes and
740/// replication still streams, but segment-continuity verification at
741/// recovery, the snapshot/journal cross-check, and cross-node divergence
742/// detection are all absent — a rejoining ex-primary carrying a
743/// journaled-but-unreplicated suffix gets streamed to rather than
744/// resynced. A deployment can arrive here without meaning to, because
745/// `--no-default-features` anywhere in the dependency graph turns the
746/// chain off wholesale, so boot says so out loud rather than leaving the
747/// absence to be discovered during an incident.
748///
749/// `warn!` rather than `error!`: it is a deliberate build configuration,
750/// not a malfunction.
751fn warn_if_chain_disabled() {
752    #[cfg(not(feature = "hash-chain"))]
753    warn!(
754        "built without the `hash-chain` feature: journal tamper evidence, \
755         segment-continuity and snapshot cross-checks at recovery, and \
756         cross-node divergence detection are all disabled"
757    );
758}
759
760/// Log the layout the node runs, and warn when it leaves a mandatory
761/// thread without a core. Every request passes through those threads, so
762/// a core shared with whatever else the scheduler puts there is paid for
763/// on every acknowledgement; the auxiliary threads' placement is the
764/// operator's documented trade and draws no warning. `none` is called
765/// out on its own: nothing pinned is the development layout, and figures
766/// measured under it say nothing about the node. One function for both
767/// transports, so the two paths cannot drift on what they warn about.
768///
769/// `warn!` rather than `error!`: a layout the operator chose, not a
770/// malfunction.
771fn log_layout(cores: &PipelineCores) {
772    info!(cores = %cores, "pipeline layout");
773    if cores.pins_nothing() {
774        warn!(
775            "--cores none: no pipeline thread is pinned. Every thread runs wherever the \
776             scheduler puts it and shares that core with everything else on the host. A \
777             development layout: latency measured under it is not representative. Name a \
778             core per thread in --cores"
779        );
780        return;
781    }
782    let unpinned = cores.unpinned_mandatory();
783    if !unpinned.is_empty() {
784        warn!(
785            threads = %unpinned.join(", "),
786            "--cores: mandatory pipeline threads have no core of their own. Every request \
787             passes through them, and each runs wherever the scheduler puts it, sharing \
788             that core with whatever else is there. Give them cores"
789        );
790    }
791}
792
793fn run_impl<A, L>(
794    listener: L,
795    config: ServerConfig,
796    mut startup: StartupEvents<A::Event>,
797    sizing: A::Sizing,
798    decoder: RequestDecoderArc<A>,
799    encoder: ResponseEncoderArc<A>,
800    event_publisher: Option<EventPublisherFn<A>>,
801    shutdown: Arc<AtomicBool>,
802) -> Result<(), Box<dyn std::error::Error>>
803where
804    A: Application + Send + 'static,
805    A::Event: Send + Sync + 'static,
806    A::Report: Send + 'static,
807    A::QueryResponse: Send + 'static,
808    L: BlockingTransportListener,
809{
810    warn_if_chain_disabled();
811
812    // The layout every spawn site below reads, refused outright if two
813    // threads would starve each other.
814    let config = ServerConfig {
815        cores: config.resolved_cores(ReaderThread::IoUring)?,
816        ..config
817    };
818    log_layout(&config.cores);
819
820    // Shared ack-policy atomic, constructed once per process and
821    // threaded through both roles. Wiring it on the replica path
822    // (where the live node has no response stage) lets an operator
823    // pre-stage the post-promotion policy with `ACK-POLICY <policy>`
824    // before issuing `PROMOTE`; the same `Arc` becomes the response
825    // stage's source of truth after the replica → primary transition.
826    let ack_policy_atomic = Arc::new(AtomicU8::new(config.ack_policy.as_u8()));
827
828    // Validate before the bind below so an invalid config has no side
829    // effects. `run_as_primary` re-checks (it is also reached from the
830    // DPDK promotion fallback, which does not pass through here).
831    if config.replication_bind.is_some() && config.standalone {
832        return Err("--replication-bind and --standalone are mutually exclusive".into());
833    }
834
835    // Bind the replication listener up front, before any pipeline thread
836    // exists and regardless of role: a bad or stolen --replication-bind
837    // fails the boot cleanly (nothing spawned yet to leak), and a
838    // replica holds its configured port from boot, so a later promotion
839    // can neither lose the port to another process nor die on a
840    // mid-failover bind. Pending connections just sit in the backlog
841    // until a promotion starts the sender's accept loop — replicas dial
842    // the configured primary, not each other, so nothing queues there
843    // in normal operation.
844    let repl_listener = config
845        .replication_bind
846        .map(bind_replication_listener)
847        .transpose()?;
848
849    // Replica mode: connect to primary, receive journal stream, replay.
850    // Must run before init_engine — the replica's journal is created from
851    // the primary's segment lineage during the replication handshake.
852    if let Some(primary_addr) = config.replica_of {
853        info!(primary = %primary_addr, "starting in replica mode");
854        // A replica receives the genesis events in the primary's history
855        // and never journals its own, promoted or not — nothing to hold
856        // them for, and a large genesis is worth freeing.
857        startup.genesis = Vec::new();
858
859        // Load replication signing key.
860        let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
861            std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
862        })?;
863        let signing_key = {
864            let seed = std::fs::read(replication_key_path).map_err(|e| {
865                std::io::Error::other(format!(
866                    "failed to read replication key {}: {e}",
867                    replication_key_path.display()
868                ))
869            })?;
870            if seed.len() != 32 {
871                return Err(format!(
872                    "replication key must be 32 bytes, got {} ({})",
873                    seed.len(),
874                    replication_key_path.display()
875                )
876                .into());
877            }
878            let mut bytes = [0u8; 32];
879            bytes.copy_from_slice(&seed);
880            ed25519_dalek::SigningKey::from_bytes(&bytes)
881        };
882
883        // Load authorized keys early — the admin listener needs them for
884        // Ed25519 challenge-response auth (operator keys only).
885        let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
886        info!(
887            keys = authorized_keys.len(),
888            path = %config.authorized_keys.display(),
889            "loaded authorized keys (replica mode)"
890        );
891
892        // The replica's control-plane bundle: promotion request (admin
893        // PROMOTE / raft auto-promotion), tip readiness + advertised
894        // journal tip (vote recency), primary link state, and the
895        // primary's advertised ack policy. Constructed before
896        // mode-detection so it survives a replica → primary transition.
897        // The rotate flag is re-wired into the new primary's journal
898        // stage by `run_as_primary`.
899        let control = crate::replication::ReplicaControlPlane::new();
900        let promotion_request = control.promote.clone();
901        let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
902        let _admin_handle = config
903            .admin_bind
904            .map(|addr| {
905                crate::admin::spawn(
906                    addr,
907                    Some(promotion_request.clone()),
908                    rotate_flag.clone(),
909                    Some(Arc::clone(&ack_policy_atomic)),
910                    Arc::clone(&shutdown),
911                    Arc::clone(&authorized_keys),
912                )
913            })
914            .transpose()?;
915
916        // Shared fencing state, created before mode detection so it
917        // survives a replica → primary transition. Seeded at epoch 0; the
918        // receiver raises it from the replica's recovered journal and the
919        // replication stream, and a promotion bumps it by injecting an
920        // `EpochBump`. Carried into `run_as_primary` post-promotion.
921        let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
922
923        // Control-plane raft (observational election). Spawned before the
924        // receive loop so the driver — like the fence state and admin
925        // flags — survives a replica → primary promotion untouched: it
926        // shares the process shutdown flag and runs on its own thread.
927        // The advertised tip isn't trustworthy until journal recovery
928        // seeds the fence epoch and sequence, so `control.tip_ready`
929        // starts false and the receiver flips it — until then the driver
930        // refuses to grant votes. The receiver owns the sequence half
931        // until a promotion hands it to the new primary's journal stage.
932        let journal_tip = control.journal_tip.clone();
933        let mut raft = match crate::raft::build_raft_config(&config)? {
934            None => crate::raft::RaftDriverGuard::disabled(&shutdown),
935            Some(cfg) => crate::raft::spawn_raft_driver(
936                cfg,
937                config.raft_auto_promote,
938                &signing_key,
939                &authorized_keys,
940                &fence_state,
941                journal_tip.clone(),
942                Arc::clone(&control.tip_ready),
943                // A replica claims to be serving once a promotion is in
944                // flight (see SupersessionPolicy).
945                {
946                    let promote = control.promote.clone();
947                    Arc::new(move || promote.is_requested())
948                },
949                &shutdown,
950            )?,
951        };
952        // Act on election wins when the operator opted in. Genesis
953        // primaries have nothing to promote — replica paths only. The
954        // guard joins the thread on every exit path.
955        if config.raft_auto_promote
956            && let Some(wiring) = raft.promotion_wiring()
957        {
958            raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
959                wiring.status,
960                control.clone(),
961                Arc::clone(&fence_state),
962                Arc::clone(&ack_policy_atomic),
963                wiring.peer_tips,
964                wiring.peer_ids,
965                wiring.elect_requested,
966                wiring.elect_enabled,
967                Arc::clone(&shutdown),
968            ));
969        }
970        let raft_status = raft.status();
971        // A raft-enabled replica exposes election gauges on --health-bind;
972        // stopped explicitly before promotion so run_as_primary can rebind
973        // the port, and by its guard on every other exit path.
974        let mut replica_health = crate::raft::spawn_replica_health(
975            &config,
976            &fence_state,
977            raft_status.as_ref(),
978            Arc::clone(&control.pipeline_healthy),
979        )?;
980
981        // No local rotation triggers on the replica side: segment
982        // rotation is primary-driven (the replica adopts the boundaries
983        // announced over the replication stream), so replica journals
984        // stay bitwise mirrors of the primary's. `--max-journal-mib`
985        // and the admin `ROTATE` command only act on primaries.
986
987        match crate::replication::run_receiver::<A>(
988            primary_addr,
989            &config.journal,
990            &signing_key,
991            &shutdown,
992            &control,
993            config.snapshot_interval_ms,
994            config.shadow_snapshot_path(),
995            config.cores,
996            config.journal_staging_mode.into(),
997            config.group_commit_delay(),
998            config.replication_pipeline_depth,
999            Arc::clone(&fence_state),
1000            &sizing,
1001        )? {
1002            // Clean shutdown — the raft and health guards tear down on drop.
1003            None => return Ok(()),
1004            Some((mut app, writer)) => {
1005                // Promotion! Transition to primary mode. Bump the epoch so a
1006                // paused/partitioned ex-primary is fenced when it reconnects.
1007                info!("replica promoted — transitioning to primary");
1008                // Release --health-bind before run_as_primary rebinds it
1009                // with the full primary health state.
1010                replica_health.stop();
1011                // A replica that ran a pipeline sized this instance
1012                // already; one promoted before its first session did
1013                // not (recovered from disk, never streamed). Sizing is
1014                // idempotent, so size here either way.
1015                <A as Application>::prefault(&mut app, &sizing);
1016
1017                // A ROTATE received while this node was a replica latched
1018                // the flag but rotated nothing (rotation is primary-driven
1019                // and replicas follow the primary's boundaries). Clear it
1020                // so the stale latch can't fire a surprise rotation on the
1021                // first post-promotion fsync.
1022                if let Some(ref flag) = rotate_flag {
1023                    flag.store(false, Ordering::Release);
1024                }
1025
1026                // The raft guard drops — stopping the driver and joining
1027                // the (already exited) promotion thread — after this
1028                // returns, so the driver serves elections and the fencing
1029                // channel for the whole primary tenure.
1030                return run_as_primary::<A, L>(
1031                    app,
1032                    writer,
1033                    listener,
1034                    repl_listener,
1035                    &config,
1036                    startup,
1037                    decoder,
1038                    encoder,
1039                    event_publisher,
1040                    Arc::clone(&shutdown),
1041                    authorized_keys,
1042                    false, // no seeding needed — state comes from replication
1043                    rotate_flag,
1044                    ack_policy_atomic,
1045                    fence_state,
1046                    promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
1047                    raft_status,
1048                    journal_tip,
1049                );
1050            }
1051        }
1052    }
1053    // Load authorized keys for challenge-response authentication.
1054    let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
1055    info!(
1056        keys = authorized_keys.len(),
1057        path = %config.authorized_keys.display(),
1058        "loaded authorized keys"
1059    );
1060
1061    // Spawn the admin listener once if configured. PROMOTE is rejected
1062    // (with ERR) on a primary because no promote flag is wired here —
1063    // promotion is meaningful only on a replica. ROTATE is wired
1064    // whenever the admin endpoint is configured.
1065    let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
1066    let _admin_handle = config
1067        .admin_bind
1068        .map(|addr| {
1069            crate::admin::spawn(
1070                addr,
1071                None,
1072                rotate_flag.clone(),
1073                Some(Arc::clone(&ack_policy_atomic)),
1074                Arc::clone(&shutdown),
1075                Arc::clone(&authorized_keys),
1076            )
1077        })
1078        .transpose()?;
1079
1080    // Initialize or recover the app. `needs_seeding` is true on first
1081    // startup — the genesis events will flow through the pipeline later.
1082    let (mut app, writer, needs_seeding, recovered_epoch) =
1083        init_engine::<A, BufferedWriter<A::Event>>(&config, &sizing)?;
1084
1085    // Size and pre-fault application-owned memory (slabs, indices) so
1086    // growth and page faults happen now, not on the hot path. Runs on the
1087    // recovered state too: a snapshot restores contents, not capacity,
1088    // and `init_engine` sizes a genesis instance before replay only.
1089    <A as Application>::prefault(&mut app, &sizing);
1090
1091    // A primary booting directly (not via promotion) keeps whatever epoch
1092    // its journal recovered; no bump.
1093    let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
1094        recovered_epoch,
1095    ));
1096
1097    // Control-plane raft (observational election). The primary needs the
1098    // replication signing key only when raft is on — peer links
1099    // authenticate with it (build_raft_config enforces the flag).
1100    // A primary's fence epoch is already recovered here, so its tip is
1101    // trustworthy from the moment the driver starts. Seed the advertised
1102    // sequence from the recovered journal for the same reason — the
1103    // pipeline's journal stage takes the handle over once it runs.
1104    let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
1105        melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
1106    );
1107    let raft = match crate::raft::build_raft_config(&config)? {
1108        None => crate::raft::RaftDriverGuard::disabled(&shutdown),
1109        Some(cfg) => {
1110            let signing_key = load_replication_key(&config)?;
1111            crate::raft::spawn_raft_driver(
1112                cfg,
1113                config.raft_auto_promote,
1114                &signing_key,
1115                &authorized_keys,
1116                &fence_state,
1117                journal_tip.clone(),
1118                Arc::new(AtomicBool::new(true)),
1119                // A primary always claims to be serving.
1120                Arc::new(|| true),
1121                &shutdown,
1122            )?
1123        }
1124    };
1125    let raft_status = raft.status();
1126
1127    // The raft guard drops — stopping the driver — after this returns.
1128    run_as_primary::<A, L>(
1129        app,
1130        writer,
1131        listener,
1132        repl_listener,
1133        &config,
1134        startup,
1135        decoder,
1136        encoder,
1137        event_publisher,
1138        Arc::clone(&shutdown),
1139        authorized_keys,
1140        needs_seeding,
1141        rotate_flag,
1142        ack_policy_atomic,
1143        fence_state,
1144        None, // not promoted — no EpochBump injection
1145        raft_status,
1146        journal_tip,
1147    )
1148}
1149
1150/// Bind the kernel-TCP replication listener, non-blocking (the sender's
1151/// accept loop polls the shutdown flag between accepts). The kernel path
1152/// binds at boot (`run_impl`) so a failure cannot leak pipeline threads
1153/// and a promotion cannot fail on it; the DPDK promotion fallback binds
1154/// at promotion, where its kernel listeners come up.
1155fn bind_replication_listener(
1156    addr: std::net::SocketAddr,
1157) -> Result<crate::replication::ReplicationListener, Box<dyn std::error::Error>> {
1158    let listener = std::net::TcpListener::bind(addr)
1159        .map_err(|e| format!("failed to bind replication listener on {addr}: {e}"))?;
1160    // The newtype sets non-blocking — the invariant the sender's accept
1161    // loop needs is enforced by construction, not by this call site.
1162    let listener = crate::replication::ReplicationListener::new(listener)
1163        .map_err(|e| format!("failed to set non-blocking on replication listener: {e}"))?;
1164    info!(%addr, "replication listener bound");
1165    Ok(listener)
1166}
1167
1168/// Load the Ed25519 replication signing key from `--replication-key` —
1169/// shared by the replica connect path and raft-enabled primaries.
1170fn load_replication_key(
1171    config: &ServerConfig,
1172) -> Result<ed25519_dalek::SigningKey, Box<dyn std::error::Error>> {
1173    let path = config.replication_key.as_ref().ok_or_else(|| {
1174        std::io::Error::other("--replication-key is required (replica mode or --raft-bind)")
1175    })?;
1176    let seed = std::fs::read(path).map_err(|e| {
1177        std::io::Error::other(format!(
1178            "failed to read replication key {}: {e}",
1179            path.display()
1180        ))
1181    })?;
1182    if seed.len() != 32 {
1183        return Err(format!(
1184            "replication key must be 32 bytes, got {} ({})",
1185            seed.len(),
1186            path.display()
1187        )
1188        .into());
1189    }
1190    let mut bytes = [0u8; 32];
1191    bytes.copy_from_slice(&seed);
1192    Ok(ed25519_dalek::SigningKey::from_bytes(&bytes))
1193}
1194
1195/// Run the server as a primary: build the disruptor pipeline, spawn
1196/// pipeline threads, journal the application's startup events, then
1197/// accept client connections.
1198///
1199/// Control event for the response stage. The io_uring response path reads
1200/// `fd` for I/O; the `writer` keeps the fd alive via ownership.
1201pub use crate::ControlEvent;
1202
1203/// Joinable handles for every long-lived thread spawned by a primary.
1204/// Optional handles are `None` when their feature is disabled (e.g.,
1205/// no replication, no health endpoint) or unsupported on a transport
1206/// (e.g., DPDK runs without an event publisher or shadow snapshotter).
1207struct PipelineHandles<A: Send + 'static, W: Send + 'static> {
1208    journal: std::thread::JoinHandle<Result<W, JournalError>>,
1209    matching: std::thread::JoinHandle<A>,
1210    response: std::thread::JoinHandle<()>,
1211    replication: Option<std::thread::JoinHandle<()>>,
1212    event_publisher: Option<std::thread::JoinHandle<()>>,
1213    shadow: Option<std::thread::JoinHandle<()>>,
1214    health: Option<std::thread::JoinHandle<()>>,
1215    // No standalone tick thread on either transport: io_uring emits ticks
1216    // from inside the reader (via IORING_OP_TIMEOUT) and DPDK emits them
1217    // from inside the poll thread (via a wall-clock check between bursts).
1218}
1219
1220/// Drain the pipeline and join every worker thread, surfacing panics
1221/// and journal-stage errors as a single `pipeline failure` return.
1222///
1223/// `extras` is a list of pre-joined results (used by the DPDK path,
1224/// which joins its poll threads before draining the pipeline).
1225fn shutdown_pipeline_stages<A: Send + 'static, W: Send + 'static>(
1226    handles: PipelineHandles<A, W>,
1227    extras: Vec<(String, std::thread::Result<()>)>,
1228    pipeline_healthy: &AtomicBool,
1229    shutdown: &AtomicBool,
1230) -> Result<(), Box<dyn std::error::Error>> {
1231    info!("shutdown: draining pipeline");
1232    pipeline_healthy.store(false, Ordering::Relaxed);
1233    shutdown.store(true, Ordering::Relaxed);
1234
1235    let mut thread_panicked = false;
1236    let mut check_join = |name: &str, result: std::thread::Result<()>| {
1237        if let Err(panic) = result {
1238            let msg = panic
1239                .downcast_ref::<&str>()
1240                .copied()
1241                .or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
1242                .unwrap_or("<non-string panic>");
1243            error!(thread = name, message = msg, "pipeline thread panicked");
1244            thread_panicked = true;
1245        }
1246    };
1247
1248    let journal_result = handles.journal.join();
1249    let journal_failed = matches!(&journal_result, Ok(Err(_)));
1250    if let Ok(Err(ref e)) = journal_result {
1251        error!(thread = "journal-seq", error = %e, "journal stage returned error");
1252    }
1253    check_join("journal-seq", journal_result.map(|_| ()));
1254    check_join("matching", handles.matching.join().map(|_| ()));
1255    check_join("response", handles.response.join());
1256    for (name, r) in extras {
1257        check_join(&name, r);
1258    }
1259    if let Some(h) = handles.replication {
1260        check_join("replication-sender", h.join());
1261    }
1262    if let Some(h) = handles.event_publisher {
1263        check_join("event-publisher", h.join());
1264    }
1265    if let Some(h) = handles.shadow {
1266        check_join("shadow", h.join());
1267    }
1268    if let Some(h) = handles.health {
1269        check_join("health", h.join());
1270    }
1271
1272    // After every stage thread has joined, dump the per-stage latency
1273    // histograms to stderr. No-op when `latency-trace` is disabled.
1274    melin_transport_core::trace::print_report_all();
1275
1276    if thread_panicked || journal_failed {
1277        error!("shutdown complete (with pipeline failure)");
1278        return Err("pipeline failure".into());
1279    }
1280
1281    info!("shutdown complete");
1282    Ok(())
1283}
1284
1285/// Used by both the normal primary startup path and the promotion path
1286/// (replica → primary transition).
1287///
1288/// `rotate_flag` is the shared `AtomicBool` toggled by the admin
1289/// endpoint (or `None` if no admin endpoint was configured). On the
1290/// promotion path the replica's journal stage is torn down and a new
1291/// primary stage is built here; passing the flag through means the
1292/// admin endpoint, which was spawned once at process start, keeps
1293/// driving the new stage's rotation.
1294#[allow(clippy::too_many_arguments)]
1295fn run_as_primary<A, L>(
1296    app: A,
1297    writer: BufferedWriter<A::Event>,
1298    mut listener: L,
1299    // Pre-bound replication listener (non-blocking by construction),
1300    // `Some` iff `--replication-bind` is set. Bound in `run_impl` before
1301    // any pipeline thread exists so a bind failure cannot leak threads,
1302    // and held from boot on replicas so a promotion cannot fail on it.
1303    repl_listener: Option<crate::replication::ReplicationListener>,
1304    config: &ServerConfig,
1305    startup: StartupEvents<A::Event>,
1306    decoder: RequestDecoderArc<A>,
1307    encoder: ResponseEncoderArc<A>,
1308    event_publisher: Option<EventPublisherFn<A>>,
1309    shutdown: Arc<AtomicBool>,
1310    authorized_keys: Arc<AuthorizedKeys>,
1311    needs_seeding: bool,
1312    rotate_flag: Option<Arc<AtomicBool>>,
1313    ack_policy_atomic: Arc<AtomicU8>,
1314    fence_state: Arc<melin_transport_core::fence::FenceState>,
1315    promotion: Option<u64>,
1316    raft_status: Option<Arc<melin_transport_core::health::RaftStatus>>,
1317    // Control-plane advertised tip. This primary's journal stage becomes
1318    // its writer (the raft driver keeps reading the same handle across a
1319    // promotion) — see `AdvertisedJournalTip`.
1320    journal_tip: melin_transport_core::AdvertisedJournalTip,
1321) -> Result<(), Box<dyn std::error::Error>>
1322where
1323    A: Application + Send + 'static,
1324    A::Event: Send + Sync + 'static,
1325    A::Report: Send + 'static,
1326    A::QueryResponse: Send + 'static,
1327    L: BlockingTransportListener,
1328{
1329    // Active connection counter shared between accept loop, response
1330    // stage, and matching stage (for stats queries).
1331    // Incremented on successful auth, decremented on disconnect.
1332    // Used to enforce max_connections (SEC-02).
1333    let active_connections = Arc::new(AtomicU64::new(0));
1334
1335    // Determine replication mode. Every application's binary ships the
1336    // full durable transport (journal + replication + shadow), so the
1337    // same config knob drives any of them.
1338    let enable_replication = config.replication_bind.is_some();
1339    if enable_replication && config.standalone {
1340        return Err("--replication-bind and --standalone are mutually exclusive".into());
1341    }
1342    // `--standalone` declares "no replicas ever" — only `disk` can be
1343    // satisfied. Every other policy requires a second node and would
1344    // stall the gate forever; reject loudly at startup with the fix in
1345    // the message.
1346    if config.standalone && config.ack_policy != crate::ack_policy::AckPolicy::Disk {
1347        return Err(format!(
1348            "--standalone requires --ack-policy disk; got `{}` (this policy needs at least one connected replica)",
1349            config.ack_policy,
1350        )
1351        .into());
1352    }
1353    // Clone the application for the shadow snapshot stage before the pipeline
1354    // consumes it. `Application` does not require `Clone`, so this goes
1355    // through `clone_via_snapshot` (a snapshot round-trip unless the
1356    // application overrides it with something cheaper).
1357    let enable_shadow = config.snapshot_interval_ms > 0;
1358    let shadow_app = if enable_shadow {
1359        Some(<A as Application>::clone_via_snapshot(&app)?)
1360    } else {
1361        None
1362    };
1363
1364    // Build the disruptor pipeline with optional replication consumer.
1365    // An application without an event publisher silently ignores
1366    // `--event-bind`, so the same invocation works against any binary.
1367    // The caller (binary) decides whether an event-publisher fn is
1368    // available; we only allocate the consumer slot when both the fn is
1369    // wired AND `--event-bind` is set.
1370    let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
1371    let Pipeline {
1372        input_producer,
1373        journal_stage,
1374        matching_stage,
1375        mut output_consumers,
1376        events_processed,
1377        input_cursor,
1378        replication_consumers,
1379        replicas_connected,
1380        shadow_consumer,
1381        chain_hash_lock,
1382        replication_ring_progress,
1383        cursors,
1384    } = build_pipeline_with_replication(
1385        app,
1386        writer,
1387        config.group_commit_delay(),
1388        Arc::clone(&active_connections),
1389        enable_replication,
1390        config.max_journal_batch,
1391        config.replication_ring_size,
1392        config.cores.stage_waits(),
1393        enable_event_publisher,
1394        enable_shadow,
1395        Arc::clone(&fence_state),
1396    );
1397    // Ring-position cursors for the seed-drain gate below (Acquire loads,
1398    // stronger than the bundle's monitoring reads). The ack gate and
1399    // the replication sender pull their typed handles straight from
1400    // `cursors`; the health endpoint takes `cursors` itself — the quorum
1401    // and fastest-replica gauges are derived from the bundle's per-slot
1402    // cursors at read time.
1403    let journal_cursor = cursors.journal_ring_arc();
1404    let matching_cursor = cursors.matching_ring_arc();
1405
1406    // Consumer 0 is always the response stage. Consumer 1 (if present)
1407    // is the event publisher — only created when --event-bind is set.
1408    let output_consumer = output_consumers.remove(0);
1409    let event_publisher_consumer = if enable_event_publisher {
1410        Some(output_consumers.remove(0))
1411    } else {
1412        None
1413    };
1414
1415    // Control channel for connect/disconnect events → response stage.
1416    let (control_tx, control_rx) = std::sync::mpsc::channel();
1417
1418    // Client writes a halted node refuses at ingress, reader → response
1419    // stage, counted for the health endpoint. See `crate::halt`.
1420    let refused_writes = Arc::new(AtomicU64::new(0));
1421    let halt_gate = crate::halt::HaltGate::new(
1422        replicas_connected.clone(),
1423        Arc::clone(&fence_state),
1424        Arc::clone(&refused_writes),
1425    );
1426    let (refusal_tx, refusal_rx) =
1427        crate::halt::refusal_channel::<A::Report>(Arc::clone(&matching_cursor));
1428
1429    // Spawn the io_uring reader thread. A single reader uses multishot RECV
1430    // to multiplex every TCP connection on the server. Pinned to
1431    // cores.reader. The matching stage is the throughput limit, so a
1432    // second reader would not raise throughput — only re-introduce the
1433    // multi-producer ordering race on the input ring.
1434    let connection_timeout = config.connection_timeout();
1435    let heartbeat_interval = config.heartbeat_interval();
1436
1437    // Startup events flow through the disruptor like regular events so
1438    // they're journaled, replicated, and processed by the matching stage via
1439    // the normal pipeline. The input ring is single-producer: main publishes
1440    // them through `input_producer`, then moves it into the reader thread
1441    // which becomes the sole steady-state producer. No cloning required.
1442    let mut input_producer = input_producer;
1443
1444    // Spawn pipeline OS threads.
1445    let cores = config.cores;
1446
1447    // Wire runtime journal rotation into the journal stage. The flag is
1448    // shared with the admin endpoint (spawned once at process start in
1449    // `run`) — passing it through here means PROMOTE-then-
1450    // ROTATE on a freshly-promoted node still drives rotation against
1451    // the new primary's stage. The size threshold uses the same
1452    // `max_journal_mib` knob that drives startup rotation.
1453    let mut journal_stage = journal_stage;
1454    let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
1455    journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
1456    config.cores.place_journal_children(&mut journal_stage);
1457    journal_stage.set_staging_mode(config.journal_staging_mode.into());
1458    // On a primary the journal stage owns the control-plane advertised
1459    // tip (durable cursor after each fsync batch). On the promotion path
1460    // this takes over the handle the receiver was advancing.
1461    journal_stage.set_advertised_tip_publisher(journal_tip.clone());
1462    if config.max_journal_mib > 0 {
1463        info!(
1464            max_journal_mib = config.max_journal_mib,
1465            "runtime journal rotation enabled (size threshold)"
1466        );
1467    }
1468
1469    // Extract utilization handles before stages are moved into threads.
1470    let journal_utilization = journal_stage.utilization();
1471    let matching_utilization = matching_stage.utilization();
1472    let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
1473
1474    // Wrap each spawn closure with a tail log so a thread that returns
1475    // (cleanly OR via `Err`) is visible in the trace stream — the
1476    // accept loop's `is_finished` check fires error!("pipeline thread
1477    // died") only on its next tick, which can lag the actual exit by
1478    // up to 100ms and races with the listener teardown.
1479    let s1 = Arc::clone(&shutdown);
1480    let shutdown_for_journal = Arc::clone(&shutdown);
1481    // Start the journal's disk and preparer threads here rather than on
1482    // journal-seq, which is pinned by the time it runs: a thread inherits
1483    // its creator's placement. See `JournalStage::start`.
1484    let sequencer = journal_stage
1485        .start()
1486        .map_err(|e| format!("start the journal stage: {e}"))?;
1487    let journal_handle = std::thread::Builder::new()
1488        .name("journal-seq".into())
1489        .spawn(move || {
1490            melin_app::affinity::pin_thread("journal-seq", cores.journal_seq.core);
1491            let result = sequencer.run(&s1);
1492            let was_shutdown = shutdown_for_journal.load(Ordering::Relaxed);
1493            match &result {
1494                Ok(_) if was_shutdown => info!("journal-seq thread exited cleanly on shutdown"),
1495                Ok(_) => error!("journal-seq thread returned without shutdown signal"),
1496                Err(e) => error!(error = %e, "journal-seq thread returned Err"),
1497            }
1498            result
1499        })
1500        .map_err(|e| format!("spawn journal-seq thread: {e}"))?;
1501
1502    let s2 = Arc::clone(&shutdown);
1503    let shutdown_for_matching = Arc::clone(&shutdown);
1504    let matching_handle = std::thread::Builder::new()
1505        .name("matching".into())
1506        .spawn(move || {
1507            melin_app::affinity::pin_thread("matching", cores.matching.core);
1508            let app = matching_stage.run(&s2);
1509            let was_shutdown = shutdown_for_matching.load(Ordering::Relaxed);
1510            if was_shutdown {
1511                info!("matching thread exited cleanly on shutdown");
1512            } else {
1513                error!("matching thread returned without shutdown signal");
1514            }
1515            app
1516        })
1517        .map_err(|e| format!("spawn matching thread: {e}"))?;
1518
1519    let replication_metrics = build_replication_metrics(
1520        replication_consumers.is_some(),
1521        &ack_policy_atomic,
1522        config.ack_policy,
1523    );
1524
1525    // Per-slot active flags exposed by the journal stage's replication
1526    // ring; the response gate filters disconnected slots out of the
1527    // policy's cursor view via these.
1528    let replica_active: Option<[Arc<AtomicBool>; 2]> =
1529        replication_ring_progress.as_ref().map(|rp| {
1530            [
1531                Arc::clone(&rp.active_flags[0]),
1532                Arc::clone(&rp.active_flags[1]),
1533            ]
1534        });
1535
1536    // Typed durable-cursor handle for the response thread's gate.
1537    let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
1538    let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
1539    let replica_active_response = replica_active.clone();
1540    let ack_policy_response = Arc::clone(&ack_policy_atomic);
1541    let s3 = Arc::clone(&shutdown);
1542    let shutdown_for_response = Arc::clone(&shutdown);
1543    let response_utilization_thread = Arc::clone(&response_utilization);
1544    let response_fence = Arc::clone(&fence_state);
1545    let active_connections_response = Arc::clone(&active_connections);
1546    let response_handle = std::thread::Builder::new()
1547        .name("response".into())
1548        .spawn(move || {
1549            melin_app::affinity::pin_thread("response", cores.response.core);
1550            crate::response::run::<A>(
1551                output_consumer,
1552                control_rx,
1553                crate::response::Response::<A> {
1554                    journal_persisted_wire_seq: journal_persisted_wire_seq_response,
1555                    ack_policy: ack_policy_response,
1556                    replication_metrics: replication_metrics_response,
1557                    replica_active: replica_active_response,
1558                    heartbeat_interval,
1559                    wait: cores.response.wait,
1560                    utilization: response_utilization_thread,
1561                    encoder,
1562                    fence_state: response_fence,
1563                    active_connections: active_connections_response,
1564                    refusals: refusal_rx,
1565                    #[cfg(test)]
1566                    pause_after_control_drain: None,
1567                },
1568                &s3,
1569            );
1570            let was_shutdown = shutdown_for_response.load(Ordering::Relaxed);
1571            if was_shutdown {
1572                info!("response thread exited cleanly on shutdown");
1573            } else {
1574                error!("response thread returned without shutdown signal");
1575            }
1576        })
1577        .map_err(|e| format!("spawn response thread: {e}"))?;
1578
1579    // Spawn replication sender thread if enabled. The journal stage publishes
1580    // encoded batches to a pre-allocated ring; the sender thread consumes them.
1581    // `replica_ready` is set when the first replica connects — seeding waits
1582    // on this to ensure seed events aren't drained before the replica arrives.
1583    let replica_ready = Arc::new(AtomicBool::new(false));
1584    // Ring depth monitoring: the producer cursors are in ReplicationRingProgress
1585    // (owned by this function), so we compute depth via ring_progress rather
1586    // than storing Box<dyn QueueCursor> in ReplicationMetrics. The health
1587    // snapshot reads consumer cursors from ReplicationMetrics and producer
1588    // cursors are not needed — ring depth is a secondary metric.
1589
1590    let replication_handle = if let Some((repl_consumer_1, repl_consumer_2)) = replication_consumers
1591    {
1592        let s_repl = Arc::clone(&shutdown);
1593        let repl_slots = cursors.replica_slot_cursors();
1594        let ready_flag = Arc::clone(&replica_ready);
1595        let connected_counter = replicas_connected
1596            .clone()
1597            .ok_or("replicas_connected must be Some when replication is enabled")?;
1598
1599        let batch_size = config.replication_batch_size;
1600        let heartbeat_secs = config.replication_heartbeat_secs;
1601        let journal_path = config.journal.clone();
1602        let repl_auth_keys = Arc::clone(&authorized_keys);
1603        let evict_flags = replication_ring_progress
1604            .as_ref()
1605            .map(|rp| {
1606                [
1607                    Arc::clone(&rp.evict_flags[0]),
1608                    Arc::clone(&rp.evict_flags[1]),
1609                ]
1610            })
1611            .unwrap_or_else(|| {
1612                [
1613                    Arc::new(AtomicBool::new(false)),
1614                    Arc::new(AtomicBool::new(false)),
1615                ]
1616            });
1617        let active_flags = replication_ring_progress
1618            .as_ref()
1619            .map(|rp| {
1620                [
1621                    Arc::clone(&rp.active_flags[0]),
1622                    Arc::clone(&rp.active_flags[1]),
1623                ]
1624            })
1625            .unwrap_or_else(|| {
1626                [
1627                    Arc::new(AtomicBool::new(false)),
1628                    Arc::new(AtomicBool::new(false)),
1629                ]
1630            });
1631        let repl_metrics = replication_metrics
1632            .clone()
1633            .ok_or("replication_metrics must be Some when replication is enabled")?;
1634        let handlers = [cores.repl_handler_0, cores.repl_handler_1];
1635        let sender_fence = Arc::clone(&fence_state);
1636        let sender_ack_policy = Arc::clone(&ack_policy_atomic);
1637        // Bound in `run_impl` before any pipeline thread was spawned —
1638        // see the `repl_listener` parameter doc.
1639        let repl_listener = repl_listener
1640            .ok_or("replication listener must be pre-bound when replication is enabled")?;
1641        let repl_accept_handle = std::thread::Builder::new()
1642            // `repl-accept`, not `repl-sender`: this thread sends nothing.
1643            // The ring consumers are moved into the per-replica handler
1644            // threads, which do all the streaming; this one accepts
1645            // connections, reaps finished handlers, and sleeps 50 ms.
1646            .name("repl-accept".into())
1647            .spawn(move || {
1648                // Deliberately NOT pinned. Reserving a core — at
1649                // SCHED_FIFO, on an isolated one — for a thread that idles
1650                // 99.9% of the time bought nothing.
1651                //
1652                // Staying unpinned is now load-bearing, not just thrifty.
1653                // The handler threads are spawned from here and inherit
1654                // this thread's affinity and scheduling policy at
1655                // creation; a child of a pinned real-time parent cannot
1656                // move itself off that core, because moving itself
1657                // requires running. That is the DPDK handshake hang, and
1658                // this path escaped it only because the 50 ms sleep
1659                // happened to yield the core. See
1660                // `replication::validation_worker`.
1661                //
1662                // `PipelineCores` consequently has no field for it, and
1663                // `--cores` no name.
1664                crate::replication::run_sender::<A>(
1665                    crate::replication::Sender {
1666                        listener: repl_listener,
1667                        repl_consumer_1,
1668                        repl_consumer_2,
1669                        replica_slots: repl_slots,
1670                        journal_path,
1671                        authorized_keys: repl_auth_keys,
1672                        evict_flags,
1673                        active_flags,
1674                        metrics: repl_metrics,
1675                        handlers,
1676                        batch_size,
1677                        heartbeat_secs,
1678                        fence_state: sender_fence,
1679                        ack_policy: sender_ack_policy,
1680                    },
1681                    &s_repl,
1682                    &ready_flag,
1683                    &connected_counter,
1684                );
1685            })
1686            .map_err(|e| format!("spawn replication sender thread: {e}"))?;
1687
1688        Some(repl_accept_handle)
1689    } else {
1690        if !config.standalone && config.replica_of.is_none() {
1691            info!("running in standalone mode (no replication)");
1692        }
1693        None
1694    };
1695
1696    // Spawn event publisher thread if enabled. Consumes from output ring
1697    // consumer 1 and broadcasts the application's reports to TCP
1698    // subscribers. Caller-supplied (an application with an output feed
1699    // wires its publisher; one without passes `None`), so the runtime
1700    // carries no application-specific reference.
1701    let event_publisher_handle = spawn_event_publisher::<A>(
1702        event_publisher_consumer,
1703        event_publisher,
1704        config,
1705        &cores,
1706        &authorized_keys,
1707        &shutdown,
1708    )?;
1709
1710    let shadow_handle = spawn_shadow_stage::<A>(
1711        shadow_consumer,
1712        shadow_app,
1713        chain_hash_lock,
1714        config,
1715        &cores,
1716        &shutdown,
1717        fence_state.epoch(),
1718    )?;
1719
1720    // Spawn the health endpoint BEFORE the wait-for-replica gate so
1721    // operators (and the failover test harness) can probe `/healthz`
1722    // to confirm the server has bound its sockets and is ready to
1723    // accept replica connections — even though seeding hasn't started
1724    // yet. The health snapshot legitimately reports `halted` while
1725    // waiting for the first replica; that's accurate, not a
1726    // misreport. Replicas connect to the *replication* endpoint, not
1727    // the client port, so spawning health here doesn't change accept
1728    // semantics.
1729    let pipeline_healthy = Arc::new(AtomicBool::new(true));
1730    let health_handle = spawn_health_endpoint(
1731        config,
1732        &active_connections,
1733        &events_processed,
1734        &refused_writes,
1735        &cursors,
1736        input_cursor,
1737        &pipeline_healthy,
1738        &replicas_connected,
1739        &fence_state,
1740        &replication_metrics,
1741        &replica_active,
1742        &replication_ring_progress,
1743        &journal_utilization,
1744        &matching_utilization,
1745        &response_utilization,
1746        &shutdown,
1747        &raft_status,
1748    )?;
1749
1750    // Promotion fencing: a node that reached primary via promotion injects
1751    // an `EpochBump` as the first journaled entry of its tenure, raising the
1752    // cluster epoch so a paused/partitioned ex-primary self-demotes when a
1753    // handshake crosses (see `melin_transport_core::fence`). Genesis primaries
1754    // skip this — their epoch stays at whatever the journal recovered. The
1755    // bump rides the input ring like a seed event (connection_id == 0), so it
1756    // flows through journal + replication to every replica. We wait for the
1757    // matching stage to apply it (epoch advanced) before serving so the first
1758    // handshake already advertises the new epoch.
1759    //
1760    // The new epoch honours the promotion request's floor: a manual
1761    // `PROMOTE` carries `MANUAL` (= 1) and resolves to the classic
1762    // `epoch + 1`; a raft auto-promotion carries its election term
1763    // (strictly above the old epoch by the driver's request rule), so
1764    // tenure epochs align with raft terms and two overlapping
1765    // promotions from different elections always allocate distinct
1766    // epochs — the newer one fences the older.
1767    if let Some(requested_epoch) = promotion {
1768        use melin_app::unix_epoch_nanos;
1769        use melin_journal::JournalEvent;
1770        use melin_transport_core::trace::mono_trace_ns;
1771
1772        let new_epoch = fence_state.epoch().saturating_add(1).max(requested_epoch);
1773        // Re-validate the term↔epoch alignment at the moment the epoch is
1774        // minted, not just at the driver's request-time check: a streamed
1775        // `EpochBump` from a concurrent promotion elsewhere can raise the
1776        // fence during the drain, in which case `max` allocates `epoch+1`
1777        // instead of the election term. Fencing still converges (the
1778        // epochs stay distinct and the higher one wins), but the skew
1779        // makes later "epochs outran raft terms" refusals — so say what
1780        // actually happened while the evidence exists. Manual promotions
1781        // (requested == MANUAL) are exempt: they never claimed alignment.
1782        if requested_epoch > crate::promotion::PromotionRequest::MANUAL
1783            && new_epoch != requested_epoch
1784        {
1785            warn!(
1786                new_epoch,
1787                requested_epoch,
1788                "promotion epoch does not match its election term — a concurrent \
1789                 promotion advanced the fencing epoch mid-drain; auto-promotion \
1790                 refusals may report term/epoch misalignment until a newer election"
1791            );
1792        }
1793        info!(
1794            new_epoch,
1795            requested_epoch, "promotion: injecting epoch bump"
1796        );
1797        input_producer.publish(InputSlot {
1798            connection_id: 0,
1799            key_hash: 0,
1800            sequence: 0,
1801            timestamp_ns: unix_epoch_nanos(),
1802            event: JournalEvent::EpochBump { epoch: new_epoch },
1803            publish_ts: mono_trace_ns(),
1804            recv_ts: mono_trace_ns(),
1805        });
1806        // Wait until the matching stage observes the bump (epoch raised) so
1807        // the node advertises `new_epoch` on the very first handshake. Bounded
1808        // by the shutdown flag so a stuck pipeline can't wedge startup.
1809        ORCHESTRATOR_WAIT
1810            .wait_until(|| fence_state.epoch() >= new_epoch || shutdown.load(Ordering::Relaxed));
1811    }
1812
1813    // Startup events: genesis on a new journal, then `on_primary` — after
1814    // the epoch bump on promotion, so the bump stays the first entry of
1815    // the tenure. (Genesis and promotion never coincide: a promoted node
1816    // has its history from the stream.)
1817    //
1818    // When replication is enabled, a new journal waits for the first
1819    // replica to connect before publishing. replica_ready is set by the
1820    // replica handler thread after catch-up completes and it enters the
1821    // live streaming loop — this ensures the ring consumer is actively
1822    // draining before a large genesis starts flowing. The health endpoint
1823    // was spawned above so `/healthz` answers during the wait.
1824    if enable_replication && needs_seeding {
1825        info!("waiting for replica to connect before seeding...");
1826        while !replica_ready.load(Ordering::Acquire) {
1827            if shutdown.load(Ordering::Relaxed) {
1828                break;
1829            }
1830            std::thread::sleep(std::time::Duration::from_millis(100));
1831        }
1832    }
1833    let genesis = if needs_seeding {
1834        startup.genesis
1835    } else {
1836        Vec::new()
1837    };
1838    journal_startup_events(
1839        genesis,
1840        startup.on_primary,
1841        &mut input_producer,
1842        &journal_cursor,
1843        &matching_cursor,
1844        &replication_ring_progress,
1845        &shutdown,
1846    );
1847
1848    // Now that seeding is fully drained, spawn the reader thread. From here
1849    // on the reader is the sole producer on the input ring (the seed loop
1850    // has finished and its clone of `input_producer` was dropped at the end
1851    // of the block above). Any subsequent ticks the reader emits cannot
1852    // race with seed events: there are none in flight.
1853    //
1854    // If shutdown was requested while seed was draining we still spawn the
1855    // reader so the unified shutdown sequence below joins every thread.
1856    let reader_shutdown = Arc::new(AtomicBool::new(false));
1857    let mut reader_handle = crate::reader::spawn_reader::<A, _>(
1858        input_producer,
1859        decoder,
1860        halt_gate,
1861        refusal_tx,
1862        control_tx.clone(),
1863        config.cores.reader.core,
1864        connection_timeout,
1865        config.tick_interval(),
1866        Arc::clone(&reader_shutdown),
1867    );
1868
1869    // Health endpoint and `pipeline_healthy` were already spawned/created
1870    // earlier (before the wait-for-replica gate) so probes can succeed
1871    // before seeding completes.
1872
1873    // Set the listener to non-blocking so accept() returns immediately
1874    // with WouldBlock when no connection is pending. This lets the accept
1875    // loop check the shutdown flag without blocking indefinitely.
1876    // Rust's std TcpListener retries on EINTR, so signals alone can't
1877    // interrupt a blocking accept().
1878    listener.set_nonblocking(true);
1879
1880    info!(addr = %config.bind, "listening");
1881
1882    // Monotonically increasing connection ID counter. AtomicU64 because
1883    // the accept loop is the only writer, but using atomic for future
1884    // flexibility (e.g., multiple listeners).
1885    let next_connection_id = AtomicU64::new(1);
1886
1887    // Accept loop — non-blocking with 100ms sleep on WouldBlock. Each
1888    // accepted connection is registered with the reader thread (no
1889    // per-connection threads).
1890    loop {
1891        if shutdown.load(Ordering::Relaxed) {
1892            info!("shutdown signal received");
1893            break;
1894        }
1895
1896        // Detect pipeline thread death early so we don't keep accepting
1897        // connections into a broken pipeline. These threads only exit on
1898        // shutdown or panic — if one is finished while shutdown is false,
1899        // it panicked. Re-check shutdown to avoid a TOCTOU race where a
1900        // clean shutdown signal arrives between the two checks.
1901        let event_pub_died = event_publisher_handle
1902            .as_ref()
1903            .is_some_and(|h| h.is_finished());
1904        let shadow_died = shadow_handle.as_ref().is_some_and(|h| h.is_finished());
1905        if (journal_handle.is_finished()
1906            || matching_handle.is_finished()
1907            || response_handle.is_finished()
1908            || event_pub_died
1909            || shadow_died)
1910            && !shutdown.load(Ordering::Relaxed)
1911        {
1912            error!("pipeline thread died, initiating shutdown");
1913            pipeline_healthy.store(false, Ordering::Relaxed);
1914            break;
1915        }
1916
1917        let (mut std_read, mut std_write, addr) = match listener.accept() {
1918            Ok(conn) => conn,
1919            Err(e) => {
1920                if e.kind() == std::io::ErrorKind::WouldBlock {
1921                    // No pending connection — sleep briefly then retry.
1922                    std::thread::sleep(std::time::Duration::from_millis(100));
1923                    continue;
1924                }
1925                error!(error = %e, "accept error");
1926                continue;
1927            }
1928        };
1929
1930        // Enforce max_connections limit (SEC-02). Reject early before
1931        // spending time on auth. The counter is decremented by the response
1932        // stage on disconnect or write error.
1933        if config.max_connections > 0
1934            && active_connections.load(Ordering::Relaxed) >= config.max_connections
1935        {
1936            warn!(addr = %addr, "connection rejected: max_connections reached");
1937            drop(std_read);
1938            drop(std_write);
1939            continue;
1940        }
1941
1942        let connection_id = ConnectionId(next_connection_id.fetch_add(1, Ordering::Relaxed));
1943
1944        debug!(connection_id = connection_id.0, addr = %addr, "new connection");
1945
1946        // Set a 5-second read timeout for the auth handshake to prevent
1947        // slow/malicious clients from blocking the accept loop.
1948        if let Err(e) = set_read_timeout(&std_read, Some(std::time::Duration::from_secs(5))) {
1949            debug!(connection_id = connection_id.0, error = %e, "failed to set auth timeout");
1950        }
1951
1952        // Challenge-response authentication handshake (cold path).
1953        // 1. Send Challenge with random nonce
1954        // 2. Read ChallengeResponse (signature + public key)
1955        // 3. Verify signature and look up key in authorized_keys
1956        // 4. Send ServerReady on success, AuthFailed on failure
1957        let (permission, public_key_bytes) = match authenticate_connection(
1958            connection_id,
1959            addr,
1960            &mut std_read,
1961            &mut std_write,
1962            &authorized_keys,
1963        ) {
1964            Ok(pair) => pair,
1965            Err(e) => {
1966                debug!(connection_id = connection_id.0, addr = %addr, error = %e, "auth failed, dropping");
1967                continue;
1968            }
1969        };
1970
1971        // The identity the application sees as `ApplyCtx::key_hash`.
1972        let key_hash = melin_app::key_hash(&public_key_bytes);
1973
1974        active_connections.fetch_add(1, Ordering::Relaxed);
1975
1976        // Clear the read timeout before handing to the io_uring reader.
1977        // io_uring uses kernel-managed I/O, so the timeout is irrelevant,
1978        // but clearing it avoids surprising behavior if the fd is ever
1979        // used in blocking mode again.
1980        if let Err(e) = set_read_timeout(&std_read, None) {
1981            debug!(connection_id = connection_id.0, error = %e, "failed to clear auth timeout");
1982        }
1983
1984        // Enable SO_BUSY_POLL on the client data socket. The reader
1985        // thread already busy-spins on io_uring CQEs, so the kernel's
1986        // NIC busy-poll happens during cycles that would have been
1987        // spent spinning anyway — net cost is zero, and we avoid the
1988        // softirq → wakeup handoff for every recv on this connection.
1989        if let Err(e) = set_busy_poll(&std_read, BUSY_POLL_US) {
1990            // Best-effort: a kernel without CAP_NET_ADMIN or with
1991            // SO_BUSY_POLL disabled by sysctl will reject this. Logged
1992            // as debug — it's a per-connection event and only affects
1993            // latency, not correctness.
1994            debug!(
1995                connection_id = connection_id.0,
1996                error = %e,
1997                "failed to set SO_BUSY_POLL on client socket"
1998            );
1999        }
2000
2001        // Belt-and-braces write timeout (SEC-01). The real slow-client
2002        // protection is in the response stage — MSG_DONTWAIT sends,
2003        // paced retries, and the blocked-send timeout — since io_uring
2004        // SEND does not honor SO_SNDTIMEO. This bounds any residual
2005        // blocking write(2) on this fd (none exist today; the response
2006        // stage is the sole egress writer).
2007        if let Err(e) = set_write_timeout(&std_write, Some(std::time::Duration::from_secs(5))) {
2008            debug!(connection_id = connection_id.0, error = %e, "failed to set write timeout");
2009        }
2010
2011        // Register the writer with the response thread before the
2012        // reader, and keep it that way: the response stage reads its
2013        // output ring before it drains this channel, and relies on
2014        // `Connected` being queued before any request of the connection
2015        // can be read — see the drain in `response::run`. Queued, not
2016        // yet applied: the stage picks the event up on its next
2017        // iteration, which that order makes early enough.
2018        let fd = std_write.as_raw_fd();
2019        let boxed_writer: Box<dyn std::io::Write + Send> = Box::new(std_write);
2020        let control_event = ControlEvent::Connected {
2021            connection_id: connection_id.0,
2022            fd,
2023            writer: BlockingFrameWriter::new(boxed_writer),
2024        };
2025        if control_tx.send(control_event).is_err() {
2026            info!("response thread gone, shutting down");
2027            break;
2028        }
2029
2030        // Register the reader fd with the io_uring reader thread.
2031        reader_handle.register(crate::reader::ReaderRegistration {
2032            connection_id,
2033            reader: std_read,
2034            addr,
2035            permission,
2036            key_hash,
2037        });
2038    }
2039
2040    // --- Ordered shutdown sequence ---
2041    // 1. Stop readers first so no new events enter the disruptor.
2042    info!("shutdown: stopping reader thread");
2043    reader_handle.shutdown();
2044    reader_handle.join();
2045
2046    // 2. Now drain the pipeline and join every worker thread.
2047    shutdown_pipeline_stages(
2048        PipelineHandles {
2049            journal: journal_handle,
2050            matching: matching_handle,
2051            response: response_handle,
2052            replication: replication_handle,
2053            event_publisher: event_publisher_handle,
2054            shadow: shadow_handle,
2055            health: health_handle,
2056        },
2057        Vec::new(),
2058        &pipeline_healthy,
2059        &shutdown,
2060    )
2061}
2062
2063/// Run the server with DPDK kernel-bypass networking.
2064///
2065/// Replaces the kernel TCP stack entirely. The DPDK poll thread handles
2066/// all NIC I/O and TCP processing via smoltcp. The response stage encodes
2067/// frames and pushes them through an mpsc channel to the poll thread.
2068///
2069/// Thread layout:
2070/// - Core N:   DPDK poll thread (rx_burst, smoltcp, frame decode, tx_burst)
2071/// - Core 1:   Journal stage
2072/// - Core 2:   Matching stage
2073/// - Core 3:   Response stage (encodes to TX channel)
2074///
2075/// See [`run`] for the role of `startup`.
2076#[cfg(feature = "dpdk")]
2077fn run_dpdk<A>(
2078    config: ServerConfig,
2079    startup: StartupEvents<A::Event>,
2080    sizing: A::Sizing,
2081    decoder: RequestDecoderArc<A>,
2082    encoder: ResponseEncoderArc<A>,
2083    event_publisher: Option<EventPublisherFn<A>>,
2084    shutdown: Arc<AtomicBool>,
2085) -> Result<(), Box<dyn std::error::Error>>
2086where
2087    A: Application + Send + 'static,
2088    A::Event: Send + Sync + 'static,
2089    A::Report: Send + 'static,
2090    A::QueryResponse: Send + 'static,
2091{
2092    let dpdk_config = dpdk_config_from(&config)?;
2093
2094    run_dpdk_impl::<A>(
2095        config,
2096        startup,
2097        sizing,
2098        decoder,
2099        encoder,
2100        event_publisher,
2101        dpdk_config,
2102        shutdown,
2103    )
2104}
2105
2106/// Translate the CLI surface into a [`melin_dpdk::DpdkConfig`].
2107///
2108/// Every operator-supplied string is validated here, before EAL init and
2109/// before any thread is spawned, and a bad value is returned as an error
2110/// rather than a panic. That ordering is the point: the peer MAC is only
2111/// consumed inside the replica's reconnect loop, so parsing it there
2112/// would turn a typo into a crash of an already-running node.
2113#[cfg(feature = "dpdk")]
2114fn dpdk_config_from(cfg: &ServerConfig) -> Result<melin_dpdk::DpdkConfig, String> {
2115    let parse_ip = |value: &str, flag: &str| -> Result<std::net::Ipv4Addr, String> {
2116        value
2117            .parse()
2118            .map_err(|e| format!("invalid {flag} address '{value}': {e}"))
2119    };
2120    let parse_mac = |value: &str, flag: &str| -> Result<[u8; 6], String> {
2121        melin_dpdk::try_parse_mac(value).map_err(|e| format!("invalid {flag}: {e}"))
2122    };
2123
2124    Ok(melin_dpdk::DpdkConfig {
2125        eal_args: cfg
2126            .dpdk_eal_args
2127            .split_whitespace()
2128            .map(String::from)
2129            .collect(),
2130        port_ids: cfg.dpdk_ports.clone(),
2131        ip_addr: parse_ip(&cfg.dpdk_ip, "--dpdk-ip")?,
2132        prefix_len: cfg.dpdk_prefix_len,
2133        gateway: cfg
2134            .dpdk_gateway
2135            .as_deref()
2136            .map(|s| parse_ip(s, "--dpdk-gateway"))
2137            .transpose()?,
2138        listen_port: cfg.bind.port(),
2139        mtu: cfg.dpdk_mtu,
2140        vlan_id: cfg.dpdk_vlan,
2141        peer_ip: cfg
2142            .dpdk_peer_ip
2143            .as_deref()
2144            .map(|s| parse_ip(s, "--dpdk-peer-ip"))
2145            .transpose()?,
2146        gateway_mac: cfg
2147            .dpdk_gateway_mac
2148            .as_deref()
2149            .map(|s| parse_mac(s, "--dpdk-gateway-mac"))
2150            .transpose()?,
2151        peer_mac: cfg
2152            .dpdk_peer_mac
2153            .as_deref()
2154            .map(|s| parse_mac(s, "--dpdk-peer-mac"))
2155            .transpose()?,
2156        num_queues: 1,
2157    })
2158}
2159
2160#[cfg(feature = "dpdk")]
2161fn run_dpdk_impl<A>(
2162    config: ServerConfig,
2163    mut startup: StartupEvents<A::Event>,
2164    sizing: A::Sizing,
2165    decoder: RequestDecoderArc<A>,
2166    encoder: ResponseEncoderArc<A>,
2167    event_publisher: Option<EventPublisherFn<A>>,
2168    dpdk_config: melin_dpdk::DpdkConfig,
2169    shutdown: Arc<AtomicBool>,
2170) -> Result<(), Box<dyn std::error::Error>>
2171where
2172    A: Application + Send + 'static,
2173    A::Event: Send + Sync + 'static,
2174    A::Report: Send + 'static,
2175    A::QueryResponse: Send + 'static,
2176{
2177    warn_if_chain_disabled();
2178
2179    // As on the kernel-TCP path: resolve the layout once, up front. The
2180    // reader entry pins the NIC poll thread here, which never yields.
2181    let config = ServerConfig {
2182        cores: config.resolved_cores(ReaderThread::DpdkPoll)?,
2183        ..config
2184    };
2185    log_layout(&config.cores);
2186    // The one placement the layout check cannot make honest: the poll
2187    // thread polls flat out whatever its entry says, and unpinned it
2188    // does so wherever the scheduler puts it — next to whatever else is
2189    // there. Accepted (test hosts run this way), but said out loud.
2190    if !config.cores.reader.is_pinned() {
2191        warn!(
2192            "--cores: the reader is unpinned, but on DPDK it is the NIC poll thread and \
2193             busy-polls regardless; it will take a full core wherever the scheduler places \
2194             it. Give it a core of its own"
2195        );
2196    }
2197
2198    // Mirrors the kernel-TCP `run` path: one atomic per
2199    // process, threaded into both replica (pre-staging for promotion)
2200    // and primary admin listeners.
2201    let ack_policy_atomic = Arc::new(AtomicU8::new(config.ack_policy.as_u8()));
2202    // Initialize shared DPDK resources (EAL, mempool, ports with N queues).
2203    let shared = melin_dpdk::DpdkShared::init(&dpdk_config)?;
2204    // Actual queue count may be less than requested (TAP only supports 1).
2205    let num_dpdk_threads = shared.num_queues as usize;
2206
2207    // --- Replica mode (DPDK) ---
2208    // If --replica-of is set, run the DPDK replication receiver instead
2209    // of the primary path. The receiver uses one queue pair for the
2210    // outbound connection to the primary.
2211    if let Some(primary_addr) = config.replica_of {
2212        info!(primary = %primary_addr, "starting in replica mode (DPDK)");
2213        // As on the kernel-TCP path: a replica never journals genesis.
2214        startup.genesis = Vec::new();
2215
2216        // Load authorized keys early — the admin listener needs them for
2217        // Ed25519 challenge-response auth (operator keys only).
2218        let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
2219        info!(
2220            keys = authorized_keys.len(),
2221            path = %config.authorized_keys.display(),
2222            "loaded authorized keys (DPDK replica mode)"
2223        );
2224
2225        // Load the replication signing key — the replica signs the primary's
2226        // challenge with it. Mirrors the kernel-TCP replica path.
2227        let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
2228            std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
2229        })?;
2230        let signing_key = {
2231            let seed = std::fs::read(replication_key_path).map_err(|e| {
2232                std::io::Error::other(format!(
2233                    "failed to read replication key {}: {e}",
2234                    replication_key_path.display()
2235                ))
2236            })?;
2237            if seed.len() != 32 {
2238                return Err(format!(
2239                    "replication key must be 32 bytes, got {} ({})",
2240                    seed.len(),
2241                    replication_key_path.display()
2242                )
2243                .into());
2244            }
2245            let mut bytes = [0u8; 32];
2246            bytes.copy_from_slice(&seed);
2247            ed25519_dalek::SigningKey::from_bytes(&bytes)
2248        };
2249
2250        // The replica's control-plane bundle, same shape as the kernel
2251        // TCP replica path — see `run` for the rationale on lifetime.
2252        let control = crate::replication::ReplicaControlPlane::new();
2253        let promotion_request = control.promote.clone();
2254        let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2255        let _admin_handle = config
2256            .admin_bind
2257            .map(|addr| {
2258                crate::admin::spawn(
2259                    addr,
2260                    Some(promotion_request.clone()),
2261                    rotate_flag.clone(),
2262                    Some(Arc::clone(&ack_policy_atomic)),
2263                    Arc::clone(&shutdown),
2264                    Arc::clone(&authorized_keys),
2265                )
2266            })
2267            .transpose()?;
2268
2269        // Shared fencing state, created before mode detection so it
2270        // survives a replica → primary transition. Seeded at epoch 0; the
2271        // receiver raises it from the replica's recovered journal and the
2272        // replication stream, and a promotion bumps it by injecting an
2273        // `EpochBump`. Carried into `run_as_primary` post-promotion.
2274        let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
2275
2276        // Control-plane raft runs on kernel TCP even on DPDK nodes — the
2277        // control plane is off the hot path and must not consume a DPDK
2278        // queue. Same promotion-surviving placement and tip ownership as
2279        // the kernel replica path.
2280        let journal_tip = control.journal_tip.clone();
2281        let mut raft = match crate::raft::build_raft_config(&config)? {
2282            None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2283            Some(cfg) => crate::raft::spawn_raft_driver(
2284                cfg,
2285                config.raft_auto_promote,
2286                &signing_key,
2287                &authorized_keys,
2288                &fence_state,
2289                journal_tip.clone(),
2290                Arc::clone(&control.tip_ready),
2291                // A replica claims to be serving once a promotion is in
2292                // flight (see SupersessionPolicy).
2293                {
2294                    let promote = control.promote.clone();
2295                    Arc::new(move || promote.is_requested())
2296                },
2297                &shutdown,
2298            )?,
2299        };
2300        // Act on election wins when the operator opted in. Genesis
2301        // primaries have nothing to promote — replica paths only. The
2302        // guard joins the thread on every exit path.
2303        if config.raft_auto_promote
2304            && let Some(wiring) = raft.promotion_wiring()
2305        {
2306            raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
2307                wiring.status,
2308                control.clone(),
2309                Arc::clone(&fence_state),
2310                Arc::clone(&ack_policy_atomic),
2311                wiring.peer_tips,
2312                wiring.peer_ids,
2313                wiring.elect_requested,
2314                wiring.elect_enabled,
2315                Arc::clone(&shutdown),
2316            ));
2317        }
2318        let raft_status = raft.status();
2319        let mut replica_health = crate::raft::spawn_replica_health(
2320            &config,
2321            &fence_state,
2322            raft_status.as_ref(),
2323            Arc::clone(&control.pipeline_healthy),
2324        )?;
2325
2326        // No local rotation triggers on the replica side — rotation is
2327        // primary-driven (see the kernel-TCP receiver path).
2328
2329        // Use queue 0 for the replication receiver's smoltcp connection.
2330        // Listen port is unused (receiver connects outbound, doesn't listen),
2331        // but DpdkTransport requires one — use an ephemeral port.
2332        let mut repl_transport = melin_dpdk::DpdkTransport::from_shared_with_port(
2333            &shared,
2334            &dpdk_config,
2335            0,
2336            39999, // Ephemeral — receiver connects outbound, doesn't accept.
2337        )?;
2338        repl_transport.send_gratuitous_arp();
2339
2340        let primary_ipv4 = match primary_addr.ip() {
2341            std::net::IpAddr::V4(ip) => ip,
2342            std::net::IpAddr::V6(_) => {
2343                return Err("DPDK replication requires IPv4".into());
2344            }
2345        };
2346
2347        match crate::replication::run_receiver_dpdk::<A>(
2348            repl_transport,
2349            primary_ipv4,
2350            primary_addr.port(),
2351            &signing_key,
2352            &config.journal,
2353            &shutdown,
2354            &control,
2355            config.snapshot_interval_ms,
2356            config.shadow_snapshot_path(),
2357            config.cores,
2358            config.journal_staging_mode.into(),
2359            config.group_commit_delay(),
2360            config.replication_pipeline_depth,
2361            Arc::clone(&fence_state),
2362            &sizing,
2363        )? {
2364            // Clean shutdown — the raft and health guards tear down on drop.
2365            None => return Ok(()),
2366            Some((mut app, writer)) => {
2367                // Promotion! Transition to primary mode (DPDK).
2368                info!("replica promoted (DPDK) — transitioning to primary");
2369                // Release --health-bind before run_as_primary rebinds it.
2370                replica_health.stop();
2371                // Idempotent; see the kernel-TCP promotion path.
2372                <A as Application>::prefault(&mut app, &sizing);
2373
2374                // Clear a ROTATE latched while this node was a replica —
2375                // see the kernel-TCP promotion path.
2376                if let Some(ref flag) = rotate_flag {
2377                    flag.store(false, Ordering::Release);
2378                }
2379
2380                // TODO: run_as_primary_dpdk — for now, fall back to
2381                // kernel TCP primary after promotion.
2382                warn!("DPDK primary promotion not yet implemented — falling back to kernel TCP");
2383                let listener = melin_wire_protocol::tcp::BlockingTcpListener::bind(config.bind)?;
2384                // Unlike the kernel path (bound at boot in `run_impl`),
2385                // the fallback's kernel listeners only come up here at
2386                // promotion — the DPDK stack carried replication until
2387                // now. See `bind_replication_listener`.
2388                let repl_listener = config
2389                    .replication_bind
2390                    .map(bind_replication_listener)
2391                    .transpose()?;
2392                // The raft guard drops — stopping the driver and joining
2393                // the (already exited) promotion thread — after this
2394                // returns; see the kernel-TCP promotion path.
2395                return run_as_primary::<A, _>(
2396                    app,
2397                    writer,
2398                    listener,
2399                    repl_listener,
2400                    &config,
2401                    startup,
2402                    decoder,
2403                    encoder,
2404                    event_publisher,
2405                    Arc::clone(&shutdown),
2406                    authorized_keys,
2407                    false,
2408                    rotate_flag,
2409                    ack_policy_atomic,
2410                    fence_state,
2411                    promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
2412                    raft_status,
2413                    journal_tip,
2414                );
2415            }
2416        }
2417    }
2418
2419    // Create per-thread transports (each gets its own queue pair + smoltcp stack).
2420    // The single-queue refactor (`feat/dpdk-single-queue`) collapsed
2421    // replication onto the same queue/thread as client traffic — no
2422    // separate queue is reserved any more, so `num_client_queues`
2423    // simply tracks `num_dpdk_threads`.
2424    let num_client_queues = num_dpdk_threads;
2425    let mut transports = Vec::with_capacity(num_client_queues);
2426    for q in 0..num_client_queues {
2427        let mut transport =
2428            melin_dpdk::DpdkTransport::from_shared(&shared, &dpdk_config, q as u16)?;
2429        // Send a gratuitous ARP from the first queue so the switch learns
2430        // our VF's MAC address. Without this, SR-IOV VFs that can't enable
2431        // promiscuous mode are unreachable — the switch has no forwarding
2432        // entry and drops unicast frames to our MAC.
2433        if q == 0 {
2434            transport.send_gratuitous_arp();
2435        }
2436        transports.push(transport);
2437    }
2438
2439    // Load authorized keys for challenge-response authentication.
2440    let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
2441    info!(
2442        keys = authorized_keys.len(),
2443        path = %config.authorized_keys.display(),
2444        "loaded authorized keys"
2445    );
2446
2447    // Initialize or recover the application, then size it — see the
2448    // kernel-TCP primary path.
2449    let (mut app, writer, needs_seeding, recovered_epoch) =
2450        init_engine::<A, BufferedWriter<A::Event>>(&config, &sizing)?;
2451    <A as Application>::prefault(&mut app, &sizing);
2452
2453    // Fencing state for this DPDK primary, seeded with the recovered epoch.
2454    let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
2455        recovered_epoch,
2456    ));
2457
2458    // Control-plane raft (kernel TCP even on DPDK — off the hot path, no
2459    // DPDK queue consumed). Signing key loaded only when raft is on.
2460    // A DPDK primary's fence epoch is recovered here too; seed the
2461    // advertised sequence from the recovered journal (see the kernel
2462    // primary path). The pipeline's journal stage owns the handle below.
2463    let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
2464        melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
2465    );
2466    let raft = match crate::raft::build_raft_config(&config)? {
2467        None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2468        Some(cfg) => {
2469            let signing_key = load_replication_key(&config)?;
2470            crate::raft::spawn_raft_driver(
2471                cfg,
2472                config.raft_auto_promote,
2473                &signing_key,
2474                &authorized_keys,
2475                &fence_state,
2476                journal_tip.clone(),
2477                Arc::new(AtomicBool::new(true)),
2478                // A primary always claims to be serving.
2479                Arc::new(|| true),
2480                &shutdown,
2481            )?
2482        }
2483    };
2484    let raft_status = raft.status();
2485
2486    // Clone the application's state for the shadow snapshot stage before
2487    // moving it into the pipeline (same as the kernel TCP path).
2488    let enable_shadow = config.snapshot_interval_ms > 0;
2489    let shadow_app = if enable_shadow {
2490        Some(<A as Application>::clone_via_snapshot(&app)?)
2491    } else {
2492        None
2493    };
2494
2495    let active_connections = Arc::new(AtomicU64::new(0));
2496
2497    // Replication setup (same as TCP path).
2498    let enable_replication = config.replication_bind.is_some();
2499    if enable_replication && config.standalone {
2500        return Err("--replication-bind and --standalone are mutually exclusive".into());
2501    }
2502    // `--standalone` declares "no replicas ever" — only `disk` can be
2503    // satisfied. Every other policy requires a second node and would
2504    // stall the gate forever; reject loudly at startup with the fix in
2505    // the message.
2506    if config.standalone && config.ack_policy != crate::ack_policy::AckPolicy::Disk {
2507        return Err(format!(
2508            "--standalone requires --ack-policy disk; got `{}` (this policy needs at least one connected replica)",
2509            config.ack_policy,
2510        )
2511        .into());
2512    }
2513
2514    // Build disruptor pipeline (same flags as the kernel TCP path).
2515    // DPDK doesn't currently spawn the publisher thread (see
2516    // `event_publisher: None` in the handles bundle further down) but
2517    // gating consumer allocation on the same `Some`/`event_bind` pair
2518    // the kernel-TCP path uses means passing `None` from the binary is
2519    // honoured here too — no orphan consumer slot is wired up.
2520    let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
2521    let enable_shadow = config.snapshot_interval_ms > 0;
2522    let Pipeline {
2523        input_producer,
2524        journal_stage,
2525        matching_stage,
2526        mut output_consumers,
2527        events_processed,
2528        input_cursor,
2529        replication_consumers,
2530        replicas_connected,
2531        shadow_consumer,
2532        chain_hash_lock,
2533        replication_ring_progress,
2534        cursors,
2535    } = build_pipeline_with_replication(
2536        app,
2537        writer,
2538        config.group_commit_delay(),
2539        Arc::clone(&active_connections),
2540        enable_replication,
2541        config.max_journal_batch,
2542        config.replication_ring_size,
2543        config.cores.stage_waits(),
2544        enable_event_publisher,
2545        enable_shadow,
2546        Arc::clone(&fence_state),
2547    );
2548    // Ring-position cursors for the seed-drain gate below (Acquire loads,
2549    // stronger than the bundle's monitoring reads). The ack gate and
2550    // the replication sender pull their typed handles straight from
2551    // `cursors`; the health endpoint takes `cursors` itself.
2552    let journal_cursor = cursors.journal_ring_arc();
2553    let matching_cursor = cursors.matching_ring_arc();
2554
2555    let heartbeat_interval = config.heartbeat_interval();
2556
2557    // Seed events flow through the disruptor like regular events. The input
2558    // ring is single-producer: main publishes seeds, then moves the
2559    // producer into the DPDK poll thread. No cloning required.
2560    let mut input_producer = input_producer;
2561
2562    // The DPDK poll thread also generates the application's clock ticks via
2563    // a wall-clock comparison between NIC bursts (see `run_dpdk_poll`). The
2564    // input ring is therefore single-producer in steady state alongside the
2565    // one-shot seed loop — same property as the io_uring transport.
2566    let tick_cadence = config.tick_interval();
2567
2568    // Control channel: DPDK poll thread → response stage (connect/disconnect).
2569    let (control_tx, control_rx) = std::sync::mpsc::channel();
2570
2571    // Client writes a halted node refuses at ingress, poll thread →
2572    // response stage, counted for the health endpoint. See `crate::halt`.
2573    let refused_writes = Arc::new(AtomicU64::new(0));
2574    let halt_gate = crate::halt::HaltGate::new(
2575        replicas_connected.clone(),
2576        Arc::clone(&fence_state),
2577        Arc::clone(&refused_writes),
2578    );
2579    let (refusal_tx, refusal_rx) =
2580        crate::halt::refusal_channel::<A::Report>(Arc::clone(&matching_cursor));
2581
2582    // TX SPSC: response stage → DPDK poll thread (encoded frames).
2583    // Lock-free, fixed-size slots — no heap allocation per frame.
2584    // 4096 slots × ~140 bytes = ~560 KiB. Enough to buffer a burst
2585    // without backpressuring the response stage.
2586    // One SPSC channel per DPDK poll thread. The response stage routes
2587    // frames to the correct thread based on thread_id encoded in
2588    // connection_id bits 56..63.
2589    let mut tx_producers = Vec::with_capacity(num_dpdk_threads);
2590    let mut tx_consumers = Vec::with_capacity(num_dpdk_threads);
2591    // The response thread produces into these, so they wait its way.
2592    let wait = config.cores.response.wait;
2593    for _ in 0..num_dpdk_threads {
2594        let (tx_out, tx_rx) =
2595            melin_pipeline::spsc::channel::<crate::dpdk_response::TxFrame>(4096, wait);
2596        tx_producers.push(tx_out);
2597        tx_consumers.push(tx_rx);
2598    }
2599
2600    // Spawn pipeline threads (journal, matching — identical to TCP path).
2601    let cores = config.cores;
2602
2603    // Wire runtime rotation into the journal stage (DPDK primary path).
2604    // PROMOTE is rejected on a primary (no flag wired); ROTATE shares the
2605    // same flag the journal stage observes.
2606    let mut journal_stage = journal_stage;
2607    let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2608    let _admin_handle = config
2609        .admin_bind
2610        .map(|addr| {
2611            crate::admin::spawn(
2612                addr,
2613                None,
2614                rotate_flag.clone(),
2615                Some(Arc::clone(&ack_policy_atomic)),
2616                Arc::clone(&shutdown),
2617                Arc::clone(&authorized_keys),
2618            )
2619        })
2620        .transpose()?;
2621    let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
2622    journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
2623    config.cores.place_journal_children(&mut journal_stage);
2624    journal_stage.set_staging_mode(config.journal_staging_mode.into());
2625    // On a primary the journal stage owns the control-plane advertised
2626    // tip (durable cursor after each fsync batch). On the promotion path
2627    // this takes over the handle the receiver was advancing.
2628    journal_stage.set_advertised_tip_publisher(journal_tip.clone());
2629    if config.max_journal_mib > 0 {
2630        info!(
2631            max_journal_mib = config.max_journal_mib,
2632            "runtime journal rotation enabled (size threshold, DPDK)"
2633        );
2634    }
2635
2636    // Extract utilization handles before stages are moved into threads.
2637    let journal_utilization = journal_stage.utilization();
2638    let matching_utilization = matching_stage.utilization();
2639    let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
2640
2641    let s1 = Arc::clone(&shutdown);
2642    // As on kernel TCP: start the journal's helper threads here, not on
2643    // journal-seq. See `JournalStage::start`.
2644    let sequencer = journal_stage
2645        .start()
2646        .map_err(|e| format!("start the journal stage: {e}"))?;
2647    let journal_handle = std::thread::Builder::new()
2648        .name("journal-seq".into())
2649        .spawn(move || {
2650            melin_app::affinity::pin_thread("journal-seq", cores.journal_seq.core);
2651            sequencer.run(&s1)
2652        })
2653        .map_err(|e| format!("spawn journal-seq thread: {e}"))?;
2654
2655    let s2 = Arc::clone(&shutdown);
2656    let matching_handle = std::thread::Builder::new()
2657        .name("matching".into())
2658        .spawn(move || {
2659            melin_app::affinity::pin_thread("matching", cores.matching.core);
2660            matching_stage.run(&s2)
2661        })
2662        .map_err(|e| format!("spawn matching thread: {e}"))?;
2663
2664    let replication_metrics = build_replication_metrics(
2665        replication_consumers.is_some(),
2666        &ack_policy_atomic,
2667        config.ack_policy,
2668    );
2669
2670    let replica_active: Option<[Arc<AtomicBool>; 2]> =
2671        replication_ring_progress.as_ref().map(|rp| {
2672            [
2673                Arc::clone(&rp.active_flags[0]),
2674                Arc::clone(&rp.active_flags[1]),
2675            ]
2676        });
2677
2678    // Spawn DPDK response stage (encodes to TX channel instead of kernel sockets).
2679    let output_consumer = output_consumers.remove(0);
2680    // Typed durable-cursor handle for the response thread's gate.
2681    let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
2682    let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
2683    let replica_active_response = replica_active.clone();
2684    let ack_policy_response = Arc::clone(&ack_policy_atomic);
2685    let active_connections_response = Arc::clone(&active_connections);
2686    let s3 = Arc::clone(&shutdown);
2687    let response_utilization_thread = Arc::clone(&response_utilization);
2688    let response_handle = std::thread::Builder::new()
2689        .name("response".into())
2690        .spawn(move || {
2691            melin_app::affinity::pin_thread("response", cores.response.core);
2692            crate::dpdk_response::run::<A>(
2693                output_consumer,
2694                control_rx,
2695                journal_persisted_wire_seq_response,
2696                ack_policy_response,
2697                replication_metrics_response,
2698                replica_active_response,
2699                &s3,
2700                heartbeat_interval,
2701                active_connections_response,
2702                tx_producers,
2703                response_utilization_thread,
2704                wait,
2705                encoder,
2706                refusal_rx,
2707            );
2708        })
2709        .map_err(|e| format!("spawn response thread: {e}"))?;
2710
2711    let shadow_handle = spawn_shadow_stage::<A>(
2712        shadow_consumer,
2713        shadow_app,
2714        chain_hash_lock,
2715        &config,
2716        &cores,
2717        &shutdown,
2718        fence_state.epoch(),
2719    )?;
2720
2721    // Spawn DPDK replication sender if enabled. Uses its own DPDK queue pair
2722    // and smoltcp stack so the replication channel goes through kernel bypass.
2723    // `replication_metrics` was constructed above so the response gate can
2724    // read per-slot cursors; the sender thread shares the same instance.
2725    let replica_ready = Arc::new(AtomicBool::new(false));
2726    // Replication, if enabled: build a `DpdkReplicationDriver` for the
2727    // single client poll thread to drive. The driver's accept dispatch
2728    // hangs off the second listener we added on the client transport
2729    // earlier (port == repl_bind.port()).
2730    let (repl_driver, repl_listen_port) = if let Some((repl_consumer_1, repl_consumer_2)) =
2731        replication_consumers
2732    {
2733        let repl_bind = config
2734            .replication_bind
2735            .ok_or("replication_bind must be set when replication is enabled")?;
2736        let repl_port = repl_bind.port();
2737
2738        let repl_slots = cursors.replica_slot_cursors();
2739        let ready_flag = Arc::clone(&replica_ready);
2740        let batch_size = config.replication_batch_size;
2741        let heartbeat_secs = config.replication_heartbeat_secs;
2742        let repl_metrics = replication_metrics
2743            .clone()
2744            .ok_or("replication_metrics must be Some when replication is enabled")?;
2745        let connected_counter = replicas_connected
2746            .clone()
2747            .ok_or("replicas_connected must be Some when replication is enabled")?;
2748        let dpdk_active_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
2749            .as_ref()
2750            .map(|rp| {
2751                [
2752                    Arc::clone(&rp.active_flags[0]),
2753                    Arc::clone(&rp.active_flags[1]),
2754                ]
2755            })
2756            .unwrap_or_else(|| {
2757                [
2758                    Arc::new(AtomicBool::new(false)),
2759                    Arc::new(AtomicBool::new(false)),
2760                ]
2761            });
2762        let dpdk_evict_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
2763            .as_ref()
2764            .map(|rp| {
2765                [
2766                    Arc::clone(&rp.evict_flags[0]),
2767                    Arc::clone(&rp.evict_flags[1]),
2768                ]
2769            })
2770            .unwrap_or_else(|| {
2771                [
2772                    Arc::new(AtomicBool::new(false)),
2773                    Arc::new(AtomicBool::new(false)),
2774                ]
2775            });
2776        let journal_path = config.journal.clone();
2777
2778        // Add the replication listener to the client transport so the
2779        // poll thread accepts both client and replication connections
2780        // off the same queue.
2781        // Replication needs larger TX buffers than clients: journal batches
2782        // can be 100-200 KiB, far exceeding the 16 KiB client TX buffer.
2783        const REPL_TX_BUF: usize = 512 * 1024;
2784        const REPL_TX_QUEUE: usize = 512 * 1024;
2785        const REPL_RX_BUF: usize = 64 * 1024;
2786        transports[0]
2787            .add_listener_with_buffers(
2788                repl_port,
2789                REPL_RX_BUF,
2790                REPL_TX_BUF,
2791                REPL_TX_QUEUE,
2792                crate::replication::REPL_DISPATCH_BURST,
2793            )
2794            .map_err(|e| format!("add replication listener: {e}"))?;
2795
2796        let driver = crate::replication::DpdkReplicationDriver::new(
2797            [repl_consumer_1, repl_consumer_2],
2798            repl_slots,
2799            journal_path,
2800            ready_flag,
2801            connected_counter,
2802            dpdk_evict_flags,
2803            dpdk_active_flags,
2804            repl_metrics,
2805            batch_size,
2806            heartbeat_secs,
2807            Arc::clone(&fence_state),
2808            Arc::clone(&ack_policy_atomic),
2809            Arc::clone(&authorized_keys),
2810        )?;
2811        // Legacy text match — `lan-bench-suite.sh` `wait_for_log` keys
2812        // off "DPDK replication sender started" to know the primary
2813        // is ready to accept replicas. Kept verbatim for backward
2814        // compatibility with existing bench tooling.
2815        info!(addr = %repl_bind, "DPDK replication sender started (single-queue, in client poll thread)");
2816        (Some(driver), repl_port)
2817    } else {
2818        if !config.standalone && config.replica_of.is_none() {
2819            info!("running in standalone mode (no replication)");
2820        }
2821        (None, 0)
2822    };
2823    // The DPDK replication-sender thread used to be spawned here; with
2824    // the single-queue / single-thread refactor the driver lives inside
2825    // the client poll thread instead. Nothing to join.
2826    let replication_handle: Option<std::thread::JoinHandle<()>> = None;
2827
2828    // Seed through the pipeline if needed.
2829    //
2830    // The previous DPDK path waited here for at least one replica to
2831    // connect before seeding so the replica picked up the seed events
2832    // live (skipping a journal-catch-up step). That gate is incompatible
2833    // with the single-queue / single-thread design: the main thread IS
2834    // the poll thread that accepts replica connections, and main is
2835    // currently blocked here pre-seed → replicas have nowhere to land →
2836    // deadlock until shutdown.
2837    //
2838    // Drop the gate. Seeding proceeds unconditionally; replicas that
2839    // connect after seeding catch up via the journal protocol (a few
2840    // hundred batches at startup — measured in tens of milliseconds, not
2841    // a meaningful operational cost). The kernel-TCP path still has its
2842    // own gate via the spawned `repl-accept` thread which can accept
2843    // replicas independently of seeding; this only changes DPDK behavior.
2844    //
2845    // A DPDK primary is never a promoted one (promotion falls back to the
2846    // kernel-TCP primary), so there is no epoch bump to order against.
2847    let _ = (&enable_replication, &replica_ready); // suppress unused warnings on non-DPDK paths
2848    let genesis = if needs_seeding {
2849        startup.genesis
2850    } else {
2851        Vec::new()
2852    };
2853    journal_startup_events(
2854        genesis,
2855        startup.on_primary,
2856        &mut input_producer,
2857        &journal_cursor,
2858        &matching_cursor,
2859        &replication_ring_progress,
2860        &shutdown,
2861    );
2862
2863    // Note: the DPDK poll threads are spawned BELOW, after this seed-drain
2864    // block. That ordering is what keeps the input ring single-producer
2865    // during seeding — the same property the TCP path achieves by deferring
2866    // its `spawn_reader` call to after seed-drain.
2867
2868    let pipeline_healthy = Arc::new(AtomicBool::new(true));
2869    let health_handle = spawn_health_endpoint(
2870        &config,
2871        &active_connections,
2872        &events_processed,
2873        &refused_writes,
2874        &cursors,
2875        input_cursor,
2876        &pipeline_healthy,
2877        &replicas_connected,
2878        &fence_state,
2879        &replication_metrics,
2880        &replica_active,
2881        &replication_ring_progress,
2882        &journal_utilization,
2883        &matching_utilization,
2884        &response_utilization,
2885        &shutdown,
2886        &raft_status,
2887    )?;
2888
2889    info!(
2890        ip = %dpdk_config.ip_addr,
2891        port = dpdk_config.listen_port,
2892        num_dpdk_threads,
2893        "DPDK transport listening"
2894    );
2895
2896    let connection_timeout = config.connection_timeout();
2897    let max_conns = config.max_connections;
2898    let reader_core = config.cores.reader.core;
2899
2900    // Exactly one client poll queue (LMAX: single reader → single matcher).
2901    // Additional DPDK queues, if any, are dedicated to the replication sender
2902    // on its own thread and don't touch the input ring.
2903    assert_eq!(
2904        transports.len(),
2905        1,
2906        "expected exactly one client DPDK transport"
2907    );
2908    let transport_0 = transports.pop().expect("one client transport");
2909    let tx_rx_0 = tx_consumers.remove(0);
2910    melin_app::affinity::pin_thread("dpdk-poll-0", reader_core);
2911    crate::dpdk_transport::run_dpdk_poll::<A>(
2912        transport_0,
2913        input_producer,
2914        decoder,
2915        halt_gate,
2916        refusal_tx,
2917        control_tx,
2918        tx_rx_0,
2919        &shutdown,
2920        authorized_keys,
2921        connection_timeout,
2922        tick_cadence,
2923        max_conns,
2924        Arc::clone(&active_connections),
2925        0,
2926        repl_driver,
2927        repl_listen_port,
2928    );
2929
2930    // The client poll runs on the main thread, so no extra DPDK threads
2931    // to join here — replication sender (if enabled) is joined below.
2932    let dpdk_extras: Vec<(String, std::thread::Result<()>)> = Vec::new();
2933
2934    // The raft guard drops — stopping the driver — after this returns.
2935    shutdown_pipeline_stages(
2936        PipelineHandles {
2937            journal: journal_handle,
2938            matching: matching_handle,
2939            response: response_handle,
2940            replication: replication_handle,
2941            event_publisher: None,
2942            shadow: shadow_handle,
2943            health: health_handle,
2944        },
2945        dpdk_extras,
2946        &pipeline_healthy,
2947        &shutdown,
2948    )
2949}
2950
2951/// Journal a primary's [`StartupEvents`] — `genesis` (empty unless the
2952/// journal is new), then `on_primary` — and return once they are applied.
2953///
2954/// The events ride the input ring as the node's own (connection 0, key
2955/// hash 0: no client identity), so the normal pipeline
2956/// journals, replicates and applies them. The caller must still be the
2957/// ring's only producer, and must not serve a client until this returns:
2958/// a client request would otherwise run before the configuration in
2959/// `on_primary` is in force, and would compete with a large genesis for
2960/// pipeline time.
2961///
2962/// Gates on the journal + matching cursors (disruptor sequence space),
2963/// then on every active replication ring being consumed — the sender
2964/// threads have read every batch (sent or being sent to replicas).
2965/// Stronger than no gate, faster than waiting for replica acks, and
2966/// deadlock-free because the ring backpressures instead of dropping
2967/// batches. Every wait yields to the shutdown flag, and goes through the
2968/// orchestrator wait strategy, never a bare spin: the stage threads may
2969/// share this thread's core on a small box.
2970fn journal_startup_events<E: melin_app::AppEvent>(
2971    genesis: Vec<E>,
2972    on_primary: Vec<E>,
2973    input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
2974    journal_cursor: &melin_pipeline::padding::Sequence,
2975    matching_cursor: &melin_pipeline::padding::Sequence,
2976    replication_ring_progress: &Option<melin_transport_core::pipeline::ReplicationRingProgress>,
2977    shutdown: &AtomicBool,
2978) {
2979    use melin_app::unix_epoch_nanos;
2980    use melin_journal::JournalEvent;
2981    use melin_transport_core::trace::mono_trace_ns;
2982
2983    let (genesis_count, on_primary_count) = (genesis.len(), on_primary.len());
2984    // Nothing published: the cursors never advance past a target, so
2985    // waiting for one would hang the boot.
2986    if genesis_count + on_primary_count == 0 {
2987        return;
2988    }
2989    let start = std::time::Instant::now();
2990
2991    // `sequence: 0` — the journal stage allocates sequences in disruptor
2992    // cursor order at encode time. The runtime wraps each application
2993    // event as `JournalEvent::App` and stamps transport-level metadata.
2994    let mut last_published_seq = 0u64;
2995    for event in genesis.into_iter().chain(on_primary) {
2996        last_published_seq = input_producer.publish(InputSlot {
2997            connection_id: 0,
2998            key_hash: 0,
2999            sequence: 0,
3000            timestamp_ns: unix_epoch_nanos(),
3001            event: JournalEvent::App(event),
3002            publish_ts: mono_trace_ns(),
3003            recv_ts: mono_trace_ns(),
3004        });
3005    }
3006    let publish_elapsed = start.elapsed();
3007
3008    let target = last_published_seq + 1; // cursor = next-to-consume
3009    info!(
3010        target,
3011        journal = journal_cursor.get().load(Ordering::Relaxed),
3012        matching = matching_cursor.get().load(Ordering::Relaxed),
3013        "startup events: waiting for pipeline cursors"
3014    );
3015    ORCHESTRATOR_WAIT.wait_until(|| {
3016        shutdown.load(Ordering::Relaxed)
3017            || (journal_cursor.get().load(Ordering::Acquire) >= target
3018                && matching_cursor.get().load(Ordering::Acquire) >= target)
3019    });
3020
3021    // Inactive rings (no connected replica) were never published to, so
3022    // their producer cursor is 0 — no wait needed.
3023    if let Some(ring_progress) = replication_ring_progress {
3024        for i in 0..ring_progress.producer_cursors.len() {
3025            if !ring_progress.active_flags[i].load(Ordering::Relaxed) {
3026                continue;
3027            }
3028            let ring_target = ring_progress.producer_cursors[i].load();
3029            ORCHESTRATOR_WAIT.wait_until(|| {
3030                shutdown.load(Ordering::Relaxed)
3031                    || ring_progress.consumer_cursors[i]
3032                        .get()
3033                        .load(Ordering::Acquire)
3034                        >= ring_target
3035            });
3036        }
3037    }
3038
3039    info!(
3040        genesis_events = genesis_count,
3041        on_primary_events = on_primary_count,
3042        publish_ms = publish_elapsed.as_millis(),
3043        total_ms = start.elapsed().as_millis(),
3044        "journaled startup events through pipeline"
3045    );
3046}
3047
3048/// Bootstrap source chosen by [`init_engine`] from the on-disk layout.
3049#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3050enum BootstrapSource {
3051    /// Snapshot plus at least one journal segment (live or archived):
3052    /// restore the snapshot, replay the post-snapshot delta.
3053    SnapshotAndJournal,
3054    /// Snapshot with no journal segment at all: restore the snapshot and
3055    /// open a fresh segment continuing its sequence + chain.
3056    SnapshotOnly,
3057    /// Journal segments but no snapshot: full replay from genesis.
3058    JournalOnly,
3059    /// Nothing on disk: first-ever startup.
3060    Fresh,
3061}
3062
3063/// Pure bootstrap decision, extracted so the matrix is unit-testable.
3064///
3065/// The critical cell: `(snapshot, no live, archives)` must route through
3066/// snapshot+journal recovery — the archives may hold acked events past
3067/// the snapshot (post-rotation crash window), and only the recovery walk
3068/// replays them, verifies the lineage, and synthesizes the missing live
3069/// segment. Likewise `(no snapshot, no live, archives)` is journal
3070/// recovery, not a fresh start.
3071fn choose_bootstrap(
3072    snapshot_exists: bool,
3073    live_exists: bool,
3074    archives_exist: bool,
3075) -> BootstrapSource {
3076    let lineage_exists = live_exists || archives_exist;
3077    match (snapshot_exists, lineage_exists) {
3078        (true, true) => BootstrapSource::SnapshotAndJournal,
3079        (true, false) => BootstrapSource::SnapshotOnly,
3080        (false, true) => BootstrapSource::JournalOnly,
3081        (false, false) => BootstrapSource::Fresh,
3082    }
3083}
3084
3085/// Initialize or recover the journaled application from disk.
3086///
3087/// Returns `(app, writer, needs_seeding, recovered_epoch)`. `needs_seeding`
3088/// is true on first startup (fresh journal) — the caller must publish seed
3089/// events through the pipeline. `recovered_epoch` is the fencing epoch
3090/// recovered from the snapshot + journal (0 for a genesis node); the caller
3091/// seeds the node's `FenceState` with it. The recovery paths
3092/// (snapshot+journal, snapshot only, journal only, fresh) are
3093/// transport-level concerns and work uniformly for any `A: Application`
3094/// via `JournaledApp<A>`. Same engine initialization the TCP / DPDK paths use.
3095///
3096/// `sizing` is applied to a genesis instance before a journal is replayed
3097/// into it, so the history lands in reserved collections; the caller
3098/// sizes the result again afterwards, which covers the snapshot paths.
3099pub(crate) fn init_engine<A, W>(
3100    config: &ServerConfig,
3101    sizing: &A::Sizing,
3102) -> Result<(A, W, bool, u64), Box<dyn std::error::Error>>
3103where
3104    A: Application,
3105    W: JournalWrite<A::Event>,
3106{
3107    // Check for a snapshot: either the explicit --snapshot path, or the
3108    // default derived path (used by auto-rotation when --snapshot is not set).
3109    let derived_snap = config.journal.with_extension("snapshot");
3110    let snap_path = config.snapshot.as_deref().or_else(|| {
3111        if derived_snap.exists() {
3112            Some(derived_snap.as_path())
3113        } else {
3114            None
3115        }
3116    });
3117
3118    // The journal *lineage* exists if either the live segment or any
3119    // archived segment is on disk. Deciding on the live file alone is
3120    // how acked events used to vanish: a crash between `archive_live`
3121    // and `create_continuing` leaves archives (holding events past the
3122    // snapshot) with no live file — bootstrapping "snapshot only" from
3123    // that layout would silently rewind every event in the archives.
3124    // `JournaledApp::recover*` handles the missing-live case itself
3125    // (replays archives, synthesizes a fresh live continuing the chain).
3126    let journal_exists = config.journal.exists();
3127    let archives_exist = !melin_journal::segment::list_archives(&config.journal)?.is_empty();
3128    let snapshot_exists = snap_path.is_some_and(|p| p.exists());
3129    let mut engine: JournaledApp<A, W> =
3130        match choose_bootstrap(snapshot_exists, journal_exists, archives_exist) {
3131            BootstrapSource::SnapshotAndJournal => {
3132                let snap_path = snap_path.expect("snapshot_exists implies snap_path");
3133                info!(snapshot = %snap_path.display(), "recovering from snapshot + journal");
3134                JournaledApp::<A, W>::recover_from_snapshot(snap_path, &config.journal)?
3135            }
3136            BootstrapSource::SnapshotOnly => {
3137                // Snapshot exists but no journal segment survives at all —
3138                // recover from the snapshot alone and start a fresh segment
3139                // continuing its sequence and chain.
3140                let snap_path = snap_path.expect("snapshot_exists implies snap_path");
3141                info!(
3142                    snapshot = %snap_path.display(),
3143                    "recovering from snapshot only (no journal segments on disk)"
3144                );
3145                let (app, snap_sequence, snap_chain_hash, snap_epoch) =
3146                    melin_transport_core::snapshot::load::<A>(snap_path)?;
3147                let writer =
3148                    W::create_continuing(&config.journal, snap_sequence + 1, snap_chain_hash)?;
3149                JournaledApp::<A, W>::from_parts(app, writer, snap_epoch)
3150            }
3151            BootstrapSource::JournalOnly => {
3152                info!("recovering from journal");
3153                // Sized before the history is applied to it, as a replica
3154                // is before it applies the stream: replay must not grow
3155                // the collections the sizing would have reserved.
3156                let mut app = A::default();
3157                <A as Application>::prefault(&mut app, sizing);
3158                JournaledApp::<A, W>::recover(app, &config.journal)?
3159            }
3160            BootstrapSource::Fresh => {
3161                info!("creating new journal");
3162                JournaledApp::<A, W>::create(A::default(), &config.journal)?
3163            }
3164        };
3165
3166    // Genesis only on a genuinely fresh start — any surviving lineage
3167    // (live or archived) already contains the genesis events.
3168    let needs_seeding = !journal_exists && !archives_exist;
3169
3170    // Archive the live journal segment if it exceeds the configured
3171    // size threshold. The shadow stage owns snapshot writes; here we
3172    // only rotate the segment so disk usage stays bounded across
3173    // restarts. Recovery walks the archive chain forward from the
3174    // latest shadow snapshot.
3175    if config.max_journal_mib > 0 {
3176        let threshold = config.max_journal_mib * 1024 * 1024;
3177        let current_size = engine.journal_size();
3178        if current_size > threshold {
3179            info!(
3180                current_mib = current_size / (1024 * 1024),
3181                threshold_mib = config.max_journal_mib,
3182                "journal exceeds threshold, rotating segment"
3183            );
3184            engine.rotate_segment()?;
3185            info!("journal segment rotated successfully");
3186        }
3187    }
3188
3189    let recovered_epoch = engine.recovered_epoch();
3190    let (app, writer) = engine.into_parts();
3191    Ok((app, writer, needs_seeding, recovered_epoch))
3192}
3193
3194// ---------------------------------------------------------------------------
3195// Shared helpers — used identically by both TCP and DPDK boot paths.
3196// ---------------------------------------------------------------------------
3197
3198fn build_replication_metrics(
3199    has_replication: bool,
3200    ack_policy_atomic: &AtomicU8,
3201    config_policy: crate::ack_policy::AckPolicy,
3202) -> Option<Arc<crate::replication::ReplicationMetrics>> {
3203    let metrics = if has_replication {
3204        Some(Arc::new(crate::replication::ReplicationMetrics::default()))
3205    } else {
3206        None
3207    };
3208
3209    let active_policy =
3210        crate::ack_policy::AckPolicy::from_u8(ack_policy_atomic.load(Ordering::Relaxed))
3211            .unwrap_or(config_policy);
3212    info!(
3213        ack_policy = %active_policy,
3214        clauses = %active_policy.to_policy(),
3215        "ack policy active"
3216    );
3217
3218    metrics
3219}
3220
3221fn spawn_shadow_stage<A: Application + Send + 'static>(
3222    shadow_consumer: Option<Consumer<InputSlot<A::Event>>>,
3223    shadow_app: Option<A>,
3224    chain_hash_lock: Option<
3225        melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3226    >,
3227    config: &ServerConfig,
3228    cores: &PipelineCores,
3229    shutdown: &Arc<AtomicBool>,
3230    initial_epoch: u64,
3231) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
3232where
3233    A::Event: Send + Sync + 'static,
3234{
3235    let Some(shadow_cons) = shadow_consumer else {
3236        return Ok(None);
3237    };
3238    let snap_path = config.shadow_snapshot_path();
3239    let interval = std::time::Duration::from_millis(config.snapshot_interval_ms);
3240    let chain_hash =
3241        chain_hash_lock.ok_or("chain hash lock must be Some when shadow is enabled")?;
3242    let shadow_ex = shadow_app.ok_or("shadow application must be Some when shadow is enabled")?;
3243    let s_shadow = Arc::clone(shutdown);
3244    let shadow = cores.shadow;
3245    let shadow_initial_epoch = initial_epoch;
3246    let handle = std::thread::Builder::new()
3247        .name("shadow".into())
3248        .spawn(move || {
3249            melin_app::affinity::pin_thread("shadow", shadow.core);
3250            melin_transport_core::shadow::run(
3251                shadow_cons,
3252                shadow_ex,
3253                snap_path,
3254                interval,
3255                chain_hash,
3256                &s_shadow,
3257                shadow.wait,
3258                shadow_initial_epoch,
3259            );
3260        })
3261        .map_err(|e| format!("spawn shadow thread: {e}"))?;
3262
3263    info!(
3264        interval_ms = config.snapshot_interval_ms,
3265        path = %config.shadow_snapshot_path().display(),
3266        "shadow snapshot stage started"
3267    );
3268    Ok(Some(handle))
3269}
3270
3271#[allow(clippy::too_many_arguments)]
3272fn spawn_health_endpoint(
3273    config: &ServerConfig,
3274    active_connections: &Arc<AtomicU64>,
3275    events_processed: &Arc<AtomicU64>,
3276    refused_writes: &Arc<AtomicU64>,
3277    cursors: &melin_transport_core::PipelineCursors,
3278    input_cursor: Box<dyn melin_pipeline::ring::QueueCursor>,
3279    pipeline_healthy: &Arc<AtomicBool>,
3280    replicas_connected: &Option<Arc<std::sync::atomic::AtomicU32>>,
3281    fence_state: &Arc<melin_transport_core::fence::FenceState>,
3282    replication_metrics: &Option<Arc<crate::replication::ReplicationMetrics>>,
3283    replica_active: &Option<[Arc<AtomicBool>; 2]>,
3284    replication_ring_progress: &Option<melin_transport_core::pipeline::ReplicationRingProgress>,
3285    journal_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3286    matching_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3287    response_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3288    shutdown: &Arc<AtomicBool>,
3289    raft_status: &Option<Arc<melin_transport_core::health::RaftStatus>>,
3290) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>> {
3291    let Some(health_addr) = config.health_bind else {
3292        return Ok(None);
3293    };
3294
3295    let (repl_ring_producers, repl_ring_consumers) = replication_ring_progress
3296        .as_ref()
3297        .map(|rp| {
3298            (
3299                Some([
3300                    Arc::clone(&rp.producer_cursors[0]),
3301                    Arc::clone(&rp.producer_cursors[1]),
3302                ]),
3303                Some([
3304                    Arc::clone(&rp.consumer_cursors[0]),
3305                    Arc::clone(&rp.consumer_cursors[1]),
3306                ]),
3307            )
3308        })
3309        .unwrap_or((None, None));
3310    Ok(Some(melin_transport_core::health::spawn(
3311        health_addr,
3312        melin_transport_core::health::HealthState {
3313            active_connections: Arc::clone(active_connections),
3314            events_processed: Arc::clone(events_processed),
3315            refused_writes: Arc::clone(refused_writes),
3316            cursors: cursors.clone(),
3317            input_cursor,
3318            pipeline_healthy: Arc::clone(pipeline_healthy),
3319            replicas_connected: replicas_connected.clone(),
3320            fence_state: Some(Arc::clone(fence_state)),
3321            replication_metrics: replication_metrics.clone(),
3322            replica_active: replica_active.clone(),
3323            replication_ring_producer_cursors: repl_ring_producers,
3324            replication_ring_consumer_cursors: repl_ring_consumers,
3325            journal_utilization: Arc::clone(journal_utilization),
3326            matching_utilization: Arc::clone(matching_utilization),
3327            response_utilization: Arc::clone(response_utilization),
3328            raft: raft_status.clone(),
3329        },
3330        Arc::clone(shutdown),
3331    )?))
3332}
3333
3334/// Spawn the event-publisher thread on the affinity core configured by
3335/// `cores.event_publisher`, delegating the loop body to the
3336/// caller-supplied [`EventPublisherFn`]. Returns `Ok(None)` when either
3337/// the consumer slot wasn't wired (the pipeline build saw
3338/// `enable_event_publisher == false`) or the binary passed no
3339/// publisher fn. The runtime owns thread lifecycle and CPU pinning so
3340/// the caller's fn stays domain-only.
3341fn spawn_event_publisher<A: Application>(
3342    consumer: Option<Consumer<OutputSlot<A>>>,
3343    run_fn: Option<EventPublisherFn<A>>,
3344    config: &ServerConfig,
3345    cores: &PipelineCores,
3346    authorized_keys: &Arc<AuthorizedKeys>,
3347    shutdown: &Arc<AtomicBool>,
3348) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
3349where
3350    A::Report: Send + 'static,
3351    A::QueryResponse: Send + 'static,
3352{
3353    let (Some(event_consumer), Some(run_fn)) = (consumer, run_fn) else {
3354        return Ok(None);
3355    };
3356    let event_bind = config
3357        .event_bind
3358        .ok_or("event_bind must be set when event publisher is enabled")?;
3359    let s_event = Arc::clone(shutdown);
3360    let event_keys = Arc::clone(authorized_keys);
3361    let publisher = cores.event_publisher;
3362    let event_handle = std::thread::Builder::new()
3363        .name("event-publisher".into())
3364        .spawn(move || {
3365            melin_app::affinity::pin_thread("event-publisher", publisher.core);
3366            run_fn(
3367                event_consumer,
3368                event_bind,
3369                event_keys,
3370                &s_event,
3371                publisher.wait,
3372            );
3373        })
3374        .map_err(|e| format!("spawn event publisher thread: {e}"))?;
3375    info!(addr = %event_bind, "event publisher started");
3376    Ok(Some(event_handle))
3377}
3378
3379/// Perform challenge-response authentication on a new connection.
3380///
3381/// Runs on the accept thread (cold path, blocking). The caller must set
3382/// a read timeout on the stream before calling to prevent slow clients
3383/// from stalling the accept loop.
3384///
3385/// Uses raw `read_exact` instead of `BufReader` to avoid over-reading
3386/// bytes that belong to the first post-auth request.
3387///
3388/// Returns `(Permission, public_key_bytes)` on success.
3389fn authenticate_connection<R: std::io::Read, W: std::io::Write>(
3390    connection_id: ConnectionId,
3391    addr: SocketAddr,
3392    reader: &mut R,
3393    writer: &mut W,
3394    authorized_keys: &AuthorizedKeys,
3395) -> Result<(Permission, [u8; 32]), Box<dyn std::error::Error>> {
3396    use std::io;
3397
3398    use melin_wire_protocol::control::TransportResponse;
3399    use melin_wire_protocol::control_codec;
3400
3401    // Generate a 32-byte random nonce for this connection.
3402    // Explicit OsRng for cryptographic material (SEC-10).
3403    let mut nonce = [0u8; 32];
3404    getrandom::fill(&mut nonce).map_err(|e| io::Error::other(format!("getrandom failed: {e}")))?;
3405
3406    let mut buf = [0u8; 128];
3407    let written =
3408        control_codec::encode_transport_response(&TransportResponse::Challenge { nonce }, &mut buf)
3409            .map_err(|e| io::Error::other(format!("encode Challenge: {e}")))?;
3410    writer.write_all(&buf[..written])?;
3411    writer.flush()?;
3412
3413    // Read ChallengeResponse frame directly (no BufReader). Using raw
3414    // read_exact avoids BufReader over-reading bytes that belong to the
3415    // first post-auth request — those bytes would be lost when the
3416    // BufReader is dropped and the fd moves to the io_uring reader.
3417    let mut len_buf = [0u8; 4];
3418    reader
3419        .read_exact(&mut len_buf)
3420        .map_err(|e| io::Error::other(format!("read auth frame length: {e}")))?;
3421    let frame_len = u32::from_le_bytes(len_buf) as usize;
3422    // ChallengeResponse is 1 (tag) + 64 (signature) + 32 (public key) = 97 bytes.
3423    if frame_len > 256 {
3424        send_auth_failed(writer);
3425        return Err(io::Error::other(format!("auth frame too large: {frame_len}")).into());
3426    }
3427    let mut frame_buf = [0u8; 256];
3428    reader
3429        .read_exact(&mut frame_buf[..frame_len])
3430        .map_err(|e| io::Error::other(format!("read auth frame payload: {e}")))?;
3431
3432    let cr = match control_codec::decode_challenge_response(&frame_buf[..frame_len]) {
3433        Ok(cr) => cr,
3434        Err(e) => {
3435            send_auth_failed(writer);
3436            return Err(io::Error::other(format!("decode ChallengeResponse: {e}")).into());
3437        }
3438    };
3439
3440    let public_key_bytes = cr.public_key;
3441    let permission = crate::client_auth::verify_client(
3442        authorized_keys,
3443        &nonce,
3444        &public_key_bytes,
3445        &cr.signature,
3446    )
3447    .inspect_err(|_| send_auth_failed(writer))?;
3448
3449    // Auth succeeded — send ServerReady.
3450    let written =
3451        control_codec::encode_transport_response(&TransportResponse::ServerReady, &mut buf)
3452            .map_err(|e| io::Error::other(format!("encode ServerReady: {e}")))?;
3453    writer.write_all(&buf[..written])?;
3454    writer.flush()?;
3455
3456    debug!(
3457        connection_id = connection_id.0,
3458        addr = %addr,
3459        permission = ?permission,
3460        "authenticated"
3461    );
3462
3463    Ok((permission, public_key_bytes))
3464}
3465
3466/// Set a read timeout on a raw fd via `setsockopt(SO_RCVTIMEO)`.
3467///
3468/// Works for both TCP and UDS since both are sockets. Uses `AsRawFd`
3469/// to avoid requiring a concrete stream type.
3470fn set_read_timeout<F: std::os::unix::io::AsRawFd>(
3471    fd: &F,
3472    timeout: Option<std::time::Duration>,
3473) -> std::io::Result<()> {
3474    let tv = match timeout {
3475        Some(d) => libc::timeval {
3476            tv_sec: d.as_secs() as libc::time_t,
3477            tv_usec: d.subsec_micros() as libc::suseconds_t,
3478        },
3479        None => libc::timeval {
3480            tv_sec: 0,
3481            tv_usec: 0,
3482        },
3483    };
3484    let ret = unsafe {
3485        libc::setsockopt(
3486            fd.as_raw_fd(),
3487            libc::SOL_SOCKET,
3488            libc::SO_RCVTIMEO,
3489            &tv as *const libc::timeval as *const libc::c_void,
3490            std::mem::size_of::<libc::timeval>() as libc::socklen_t,
3491        )
3492    };
3493    if ret < 0 {
3494        return Err(std::io::Error::last_os_error());
3495    }
3496    Ok(())
3497}
3498
3499/// Enable `SO_BUSY_POLL` on a TCP data socket so the kernel busy-polls
3500/// the NIC for incoming data instead of going to sleep on the softirq
3501/// → wakeup handoff. Removes scheduler-wakeup latency from the recv
3502/// path on hardware where IRQ delivery is the dominant per-packet cost
3503/// (e.g. ixgbe-class NICs). 50 µs covers a typical LAN ack RTT.
3504///
3505/// Best-effort: requires `CAP_NET_ADMIN` (or unprivileged operation
3506/// permitted via sysctl). Failures are surfaced as warnings by the
3507/// caller — they only cost latency, not correctness, and we don't want
3508/// a misconfigured kernel to halt connection acceptance.
3509///
3510/// Only beneficial when the receiving thread is already busy-spinning
3511/// (otherwise the spin cost is wasted on idle connections). All
3512/// callers in Melin meet that condition: client reader threads spin on
3513/// io_uring CQEs, the replication sender's ack-recv thread spins, and
3514/// the bench client thread spins.
3515pub(crate) fn set_busy_poll<F: std::os::unix::io::AsRawFd>(
3516    fd: &F,
3517    micros: i32,
3518) -> std::io::Result<()> {
3519    // SAFETY: fd is a live socket fd owned by the caller for the
3520    // duration of the call; the option pointer is to a stack-local i32
3521    // with the right size for SO_BUSY_POLL.
3522    let val: libc::c_int = micros;
3523    let ret = unsafe {
3524        libc::setsockopt(
3525            fd.as_raw_fd(),
3526            libc::SOL_SOCKET,
3527            libc::SO_BUSY_POLL,
3528            &val as *const libc::c_int as *const libc::c_void,
3529            std::mem::size_of::<libc::c_int>() as libc::socklen_t,
3530        )
3531    };
3532    if ret < 0 {
3533        return Err(std::io::Error::last_os_error());
3534    }
3535    Ok(())
3536}
3537
3538/// Default `SO_BUSY_POLL` window in microseconds. Matches the value
3539/// already used on the replica receive socket; chosen to cover a
3540/// typical LAN round-trip without burning excessive CPU on quiet
3541/// connections.
3542pub(crate) const BUSY_POLL_US: i32 = 50;
3543
3544/// Set `SO_SNDTIMEO` on a socket. Prevents blocking writes from stalling
3545/// the response thread when a client stops reading (SEC-01).
3546fn set_write_timeout<F: std::os::unix::io::AsRawFd>(
3547    fd: &F,
3548    timeout: Option<std::time::Duration>,
3549) -> std::io::Result<()> {
3550    let tv = match timeout {
3551        Some(d) => libc::timeval {
3552            tv_sec: d.as_secs() as libc::time_t,
3553            tv_usec: d.subsec_micros() as libc::suseconds_t,
3554        },
3555        None => libc::timeval {
3556            tv_sec: 0,
3557            tv_usec: 0,
3558        },
3559    };
3560    let ret = unsafe {
3561        libc::setsockopt(
3562            fd.as_raw_fd(),
3563            libc::SOL_SOCKET,
3564            libc::SO_SNDTIMEO,
3565            &tv as *const libc::timeval as *const libc::c_void,
3566            std::mem::size_of::<libc::timeval>() as libc::socklen_t,
3567        )
3568    };
3569    if ret < 0 {
3570        return Err(std::io::Error::last_os_error());
3571    }
3572    Ok(())
3573}
3574
3575/// Best-effort send of AuthFailed before dropping a connection.
3576fn send_auth_failed(writer: &mut impl std::io::Write) {
3577    use melin_wire_protocol::control::TransportResponse;
3578    use melin_wire_protocol::control_codec;
3579
3580    let mut buf = [0u8; 8];
3581    if let Ok(written) =
3582        control_codec::encode_transport_response(&TransportResponse::AuthFailed, &mut buf)
3583    {
3584        let _ = writer.write_all(&buf[..written]);
3585        let _ = writer.flush();
3586    }
3587}
3588
3589#[cfg(test)]
3590mod tests {
3591    use std::io::{Read, Write};
3592    use std::os::unix::net::UnixStream;
3593
3594    use ed25519_dalek::{Signer, SigningKey};
3595    use melin_app::auth::AuthorizedKeys;
3596    use melin_app::auth::Permission;
3597    use melin_wire_protocol::control::ConnectionId;
3598    use melin_wire_protocol::control_codec::{
3599        TAG_AUTH_FAILED, TAG_CHALLENGE, TAG_CHALLENGE_RESPONSE, TAG_RESPONSE_HEARTBEAT,
3600        TAG_SERVER_READY,
3601    };
3602
3603    use super::authenticate_connection;
3604    use super::{BootstrapSource, choose_bootstrap};
3605
3606    /// `--dpdk-eal-args` takes its value only in the joined form. The
3607    /// value itself starts with a dash, and the space form let a
3608    /// forgotten value silently swallow the next flag as the EAL
3609    /// string; both mistakes must be startup errors instead.
3610    #[test]
3611    fn dpdk_eal_args_requires_the_joined_form() {
3612        use clap::Parser;
3613        let cfg = super::ServerConfig::try_parse_from([
3614            "melin-server",
3615            "--dpdk-eal-args=-l 0-7 --huge-dir /dev/hugepages",
3616        ])
3617        .expect("joined form must parse");
3618        assert_eq!(cfg.dpdk_eal_args, "-l 0-7 --huge-dir /dev/hugepages");
3619
3620        let space_form =
3621            super::ServerConfig::try_parse_from(["melin-server", "--dpdk-eal-args", "-l 0-7"]);
3622        assert!(space_form.is_err(), "space-separated form must be rejected");
3623
3624        let forgotten_value =
3625            super::ServerConfig::try_parse_from(["melin-server", "--dpdk-eal-args", "--no-mlock"]);
3626        assert!(
3627            forgotten_value.is_err(),
3628            "a forgotten value must error, not swallow the next flag"
3629        );
3630    }
3631
3632    /// Full bootstrap decision matrix. The two archive-only cells are
3633    /// the regression guard: a post-rotation crash leaves archives with
3634    /// no live segment, and bootstrapping "snapshot only" (or "fresh")
3635    /// from that layout silently rewinds every acked event held in the
3636    /// archives. Recovery must own any layout where a lineage survives.
3637    #[test]
3638    fn bootstrap_decision_routes_archives_through_recovery() {
3639        use BootstrapSource::*;
3640        // (snapshot, live, archives) → source
3641        let matrix = [
3642            ((false, false, false), Fresh),
3643            ((false, false, true), JournalOnly), // post-rotation crash, no snapshot
3644            ((false, true, false), JournalOnly),
3645            ((false, true, true), JournalOnly),
3646            ((true, false, false), SnapshotOnly),
3647            ((true, false, true), SnapshotAndJournal), // post-rotation crash
3648            ((true, true, false), SnapshotAndJournal),
3649            ((true, true, true), SnapshotAndJournal),
3650        ];
3651        for ((snap, live, arch), expected) in matrix {
3652            assert_eq!(
3653                choose_bootstrap(snap, live, arch),
3654                expected,
3655                "snapshot={snap} live={live} archives={arch}"
3656            );
3657        }
3658    }
3659
3660    /// Deterministic test key.
3661    fn test_key() -> SigningKey {
3662        SigningKey::from_bytes(&[0xAA; 32])
3663    }
3664
3665    /// Build an `AuthorizedKeys` containing the test key with the given permission.
3666    fn keys_with_test_key(perm: &str) -> AuthorizedKeys {
3667        // Encode the public key bytes as base64 with the local helper so
3668        // the test has no external codec dependency (all test keys produce
3669        // valid base64), then feed it through AuthorizedKeys::parse.
3670        let pub_bytes = test_key().verifying_key().to_bytes();
3671        let pub_b64 = base64_encode(&pub_bytes);
3672        AuthorizedKeys::parse(&format!("{perm} {pub_b64} test\n")).unwrap()
3673    }
3674
3675    /// Minimal base64 encoder for test use only. Avoids adding base64
3676    /// as a dev-dependency to the server crate.
3677    fn base64_encode(bytes: &[u8]) -> String {
3678        const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3679        let mut out = String::new();
3680        for chunk in bytes.chunks(3) {
3681            let b0 = chunk[0] as u32;
3682            let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3683            let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3684            let n = (b0 << 16) | (b1 << 8) | b2;
3685            out.push(ALPHABET[(n >> 18 & 0x3F) as usize] as char);
3686            out.push(ALPHABET[(n >> 12 & 0x3F) as usize] as char);
3687            if chunk.len() > 1 {
3688                out.push(ALPHABET[(n >> 6 & 0x3F) as usize] as char);
3689            } else {
3690                out.push('=');
3691            }
3692            if chunk.len() > 2 {
3693                out.push(ALPHABET[(n & 0x3F) as usize] as char);
3694            } else {
3695                out.push('=');
3696            }
3697        }
3698        out
3699    }
3700
3701    /// Run `authenticate_connection` on one end of a `UnixStream::pair()`,
3702    /// returning the result. Maps the error to `String` so it's `Send`.
3703    fn run_server_auth(
3704        mut stream: UnixStream,
3705        keys: AuthorizedKeys,
3706    ) -> std::thread::JoinHandle<Result<Permission, String>> {
3707        std::thread::spawn(move || {
3708            // Clone the stream so we have independent read/write halves.
3709            let mut writer = stream.try_clone().unwrap();
3710            authenticate_connection(
3711                ConnectionId(1),
3712                "127.0.0.1:0".parse().unwrap(),
3713                &mut stream,
3714                &mut writer,
3715                &keys,
3716            )
3717            .map(|(perm, _pk)| perm)
3718            .map_err(|e| e.to_string())
3719        })
3720    }
3721
3722    /// Read a length-prefixed Challenge frame and return its 32-byte
3723    /// nonce. Frame layout: `[len:u32][TAG_CHALLENGE][nonce:32]`.
3724    fn read_challenge(stream: &mut UnixStream) -> [u8; 32] {
3725        let mut len_buf = [0u8; 4];
3726        stream.read_exact(&mut len_buf).unwrap();
3727        let len = u32::from_le_bytes(len_buf) as usize;
3728        let mut payload = vec![0u8; len];
3729        stream.read_exact(&mut payload).unwrap();
3730        assert_eq!(payload[0], TAG_CHALLENGE, "expected Challenge");
3731        payload[1..33].try_into().unwrap()
3732    }
3733
3734    /// Write a length-prefixed ChallengeResponse frame, matching the
3735    /// layout the runtime's `control_codec::decode_challenge_response`
3736    /// expects: `[len:u32][TAG_CHALLENGE_RESPONSE][sig:64][pubkey:32]`.
3737    fn write_challenge_response(
3738        stream: &mut UnixStream,
3739        signature: [u8; 64],
3740        public_key: [u8; 32],
3741    ) {
3742        let mut frame = Vec::with_capacity(97);
3743        frame.push(TAG_CHALLENGE_RESPONSE);
3744        frame.extend_from_slice(&signature);
3745        frame.extend_from_slice(&public_key);
3746        stream
3747            .write_all(&(frame.len() as u32).to_le_bytes())
3748            .unwrap();
3749        stream.write_all(&frame).unwrap();
3750        stream.flush().unwrap();
3751    }
3752
3753    /// Read a Challenge frame from the client end, sign the nonce, and
3754    /// write a valid ChallengeResponse back.
3755    fn client_sign_challenge(stream: &mut UnixStream, key: &SigningKey) {
3756        let nonce = read_challenge(stream);
3757        let sig = key.sign(&nonce);
3758        write_challenge_response(stream, sig.to_bytes(), key.verifying_key().to_bytes());
3759    }
3760
3761    /// Like `client_sign_challenge` but corrupts the signature.
3762    fn client_sign_challenge_bad(stream: &mut UnixStream, key: &SigningKey) {
3763        let nonce = read_challenge(stream);
3764        let mut sig_bytes = key.sign(&nonce).to_bytes();
3765        sig_bytes[0] ^= 0xFF;
3766        write_challenge_response(stream, sig_bytes, key.verifying_key().to_bytes());
3767    }
3768
3769    /// Read one length-prefixed control frame and return its tag byte.
3770    fn read_response_tag(stream: &mut UnixStream) -> u8 {
3771        let mut len_buf = [0u8; 4];
3772        stream.read_exact(&mut len_buf).unwrap();
3773        let len = u32::from_le_bytes(len_buf) as usize;
3774        let mut buf = vec![0u8; len];
3775        stream.read_exact(&mut buf).unwrap();
3776        buf[0]
3777    }
3778
3779    #[test]
3780    fn auth_success_returns_permission() {
3781        let keys = keys_with_test_key("trader");
3782        let key = test_key();
3783        let (s1, mut s2) = UnixStream::pair().unwrap();
3784
3785        let handle = run_server_auth(s1, keys);
3786
3787        client_sign_challenge(&mut s2, &key);
3788        let resp = read_response_tag(&mut s2);
3789        assert_eq!(resp, TAG_SERVER_READY);
3790
3791        let result = handle.join().unwrap();
3792        assert_eq!(result.unwrap(), Permission::Trader);
3793    }
3794
3795    #[test]
3796    fn auth_admin_permission() {
3797        let keys = keys_with_test_key("operator");
3798        let key = test_key();
3799        let (s1, mut s2) = UnixStream::pair().unwrap();
3800
3801        let handle = run_server_auth(s1, keys);
3802
3803        client_sign_challenge(&mut s2, &key);
3804        let resp = read_response_tag(&mut s2);
3805        assert_eq!(resp, TAG_SERVER_READY);
3806
3807        assert_eq!(handle.join().unwrap().unwrap(), Permission::Operator);
3808    }
3809
3810    #[test]
3811    fn auth_unknown_key_sends_auth_failed() {
3812        let keys = AuthorizedKeys::parse("").unwrap();
3813        let key = test_key();
3814        let (s1, mut s2) = UnixStream::pair().unwrap();
3815
3816        let handle = run_server_auth(s1, keys);
3817
3818        client_sign_challenge(&mut s2, &key);
3819        let resp = read_response_tag(&mut s2);
3820        assert_eq!(resp, TAG_AUTH_FAILED);
3821
3822        assert!(handle.join().unwrap().is_err());
3823    }
3824
3825    #[test]
3826    fn auth_bad_signature_sends_auth_failed() {
3827        let keys = keys_with_test_key("operator");
3828        let key = test_key();
3829        let (s1, mut s2) = UnixStream::pair().unwrap();
3830
3831        let handle = run_server_auth(s1, keys);
3832
3833        client_sign_challenge_bad(&mut s2, &key);
3834        let resp = read_response_tag(&mut s2);
3835        assert_eq!(resp, TAG_AUTH_FAILED);
3836
3837        assert!(handle.join().unwrap().is_err());
3838    }
3839
3840    #[test]
3841    fn auth_wrong_message_type_sends_auth_failed() {
3842        let keys = keys_with_test_key("trader");
3843        let (s1, mut s2) = UnixStream::pair().unwrap();
3844
3845        let handle = run_server_auth(s1, keys);
3846
3847        // Read and discard the Challenge.
3848        read_challenge(&mut s2);
3849
3850        // Send a frame carrying a transport-heartbeat tag where a
3851        // ChallengeResponse is expected. It is the right length (97) so
3852        // the auth decoder reaches the tag check and rejects the
3853        // unexpected tag, replying AuthFailed.
3854        let mut frame = vec![0u8; 97];
3855        frame[0] = TAG_RESPONSE_HEARTBEAT;
3856        s2.write_all(&(frame.len() as u32).to_le_bytes()).unwrap();
3857        s2.write_all(&frame).unwrap();
3858        s2.flush().unwrap();
3859
3860        let resp = read_response_tag(&mut s2);
3861        assert_eq!(resp, TAG_AUTH_FAILED);
3862
3863        assert!(handle.join().unwrap().is_err());
3864    }
3865
3866    #[test]
3867    fn auth_client_disconnects_is_error() {
3868        let keys = keys_with_test_key("trader");
3869        let (s1, s2) = UnixStream::pair().unwrap();
3870
3871        let handle = run_server_auth(s1, keys);
3872
3873        // Drop immediately — server fails reading the ChallengeResponse.
3874        drop(s2);
3875
3876        assert!(handle.join().unwrap().is_err());
3877    }
3878
3879    #[test]
3880    fn auth_different_key_than_authorized_is_rejected() {
3881        // Authorize the test key, but connect with a different key.
3882        let keys = keys_with_test_key("trader");
3883        let wrong_key = SigningKey::from_bytes(&[0xCC; 32]);
3884        let (s1, mut s2) = UnixStream::pair().unwrap();
3885
3886        let handle = run_server_auth(s1, keys);
3887
3888        client_sign_challenge(&mut s2, &wrong_key);
3889        let resp = read_response_tag(&mut s2);
3890        assert_eq!(resp, TAG_AUTH_FAILED);
3891
3892        assert!(handle.join().unwrap().is_err());
3893    }
3894
3895    #[test]
3896    fn auth_oversized_frame_sends_auth_failed() {
3897        let keys = keys_with_test_key("trader");
3898        let (s1, mut s2) = UnixStream::pair().unwrap();
3899
3900        let handle = run_server_auth(s1, keys);
3901
3902        // Read and discard Challenge.
3903        read_challenge(&mut s2);
3904
3905        // Send a frame claiming to be 1000 bytes (way over the 256 limit).
3906        let fake_len: u32 = 1000;
3907        s2.write_all(&fake_len.to_le_bytes()).unwrap();
3908        s2.flush().unwrap();
3909
3910        // Server should send AuthFailed before dropping.
3911        let resp = read_response_tag(&mut s2);
3912        assert_eq!(resp, TAG_AUTH_FAILED);
3913
3914        assert!(handle.join().unwrap().is_err());
3915    }
3916
3917    #[test]
3918    fn auth_zero_length_frame_sends_auth_failed() {
3919        let keys = keys_with_test_key("trader");
3920        let (s1, mut s2) = UnixStream::pair().unwrap();
3921
3922        let handle = run_server_auth(s1, keys);
3923
3924        // Read and discard Challenge.
3925        read_challenge(&mut s2);
3926
3927        // Send a zero-length frame — decode_request will fail on empty input.
3928        let zero_len: u32 = 0;
3929        s2.write_all(&zero_len.to_le_bytes()).unwrap();
3930        s2.flush().unwrap();
3931
3932        // Server should send AuthFailed before dropping.
3933        let resp = read_response_tag(&mut s2);
3934        assert_eq!(resp, TAG_AUTH_FAILED);
3935
3936        assert!(handle.join().unwrap().is_err());
3937    }
3938
3939    #[test]
3940    fn auth_readonly_permission() {
3941        let keys = keys_with_test_key("readonly");
3942        let key = test_key();
3943        let (s1, mut s2) = UnixStream::pair().unwrap();
3944
3945        let handle = run_server_auth(s1, keys);
3946
3947        client_sign_challenge(&mut s2, &key);
3948        let resp = read_response_tag(&mut s2);
3949        assert_eq!(resp, TAG_SERVER_READY);
3950
3951        let perm = handle.join().unwrap().unwrap();
3952        assert_eq!(perm, Permission::ReadOnly);
3953        assert!(!perm.can_trade());
3954    }
3955
3956    /// A replication key signs correctly and is in the keys file, but it
3957    /// authorizes node-to-node streaming only: the client listener refuses
3958    /// it at the handshake, before any request could reach the decoder.
3959    #[test]
3960    fn auth_replication_key_refused_on_client_listener() {
3961        let keys = keys_with_test_key("replication");
3962        let key = test_key();
3963        let (s1, mut s2) = UnixStream::pair().unwrap();
3964
3965        let handle = run_server_auth(s1, keys);
3966
3967        client_sign_challenge(&mut s2, &key);
3968        let resp = read_response_tag(&mut s2);
3969        assert_eq!(resp, TAG_AUTH_FAILED);
3970
3971        let err = handle.join().unwrap().unwrap_err();
3972        assert!(err.contains("client listener"), "unexpected error: {err}");
3973    }
3974}
3975
3976/// Startup-time validation of the DPDK CLI surface.
3977///
3978/// The value these pin is *when* a bad flag is caught. `--dpdk-peer-mac`
3979/// is consumed inside the replica's reconnect loop, so the failure has to
3980/// surface here — during config translation, before EAL init and before
3981/// any thread exists — rather than as a panic in a node that is already
3982/// serving.
3983#[cfg(all(test, feature = "dpdk"))]
3984mod dpdk_config_tests {
3985    use super::{ServerConfig, dpdk_config_from};
3986
3987    #[test]
3988    fn a_supplied_peer_mac_reaches_the_transport_config() {
3989        let cfg = ServerConfig {
3990            dpdk_peer_mac: Some("0c:42:a1:5b:2e:80".into()),
3991            ..Default::default()
3992        };
3993        let dpdk = dpdk_config_from(&cfg).expect("valid config");
3994        assert_eq!(dpdk.peer_mac, Some([0x0c, 0x42, 0xa1, 0x5b, 0x2e, 0x80]));
3995    }
3996
3997    #[test]
3998    fn an_absent_peer_mac_leaves_the_transport_to_derive_one() {
3999        let cfg = ServerConfig::default();
4000        let dpdk = dpdk_config_from(&cfg).expect("valid config");
4001        assert_eq!(dpdk.peer_mac, None);
4002    }
4003
4004    #[test]
4005    fn a_malformed_peer_mac_fails_config_translation() {
4006        let cfg = ServerConfig {
4007            dpdk_peer_mac: Some("0c:42:a1:5b:2e".into()),
4008            ..Default::default()
4009        };
4010        let err = dpdk_config_from(&cfg).expect_err("a 5-octet MAC must be rejected");
4011        assert!(err.contains("--dpdk-peer-mac"), "{err}");
4012    }
4013
4014    #[test]
4015    fn a_malformed_gateway_mac_fails_config_translation() {
4016        // The same guarantee for the pre-existing flag, which used to
4017        // panic out of this function.
4018        let cfg = ServerConfig {
4019            dpdk_gateway_mac: Some("not-a-mac".into()),
4020            ..Default::default()
4021        };
4022        let err = dpdk_config_from(&cfg).expect_err("a non-MAC must be rejected");
4023        assert!(err.contains("--dpdk-gateway-mac"), "{err}");
4024    }
4025
4026    #[test]
4027    fn a_malformed_ip_names_the_flag_it_came_from() {
4028        let cfg = ServerConfig {
4029            dpdk_peer_ip: Some("10.0.0".into()),
4030            ..Default::default()
4031        };
4032        let err = dpdk_config_from(&cfg).expect_err("a truncated IPv4 must be rejected");
4033        assert!(err.contains("--dpdk-peer-ip"), "{err}");
4034    }
4035
4036    #[test]
4037    fn a_valid_configuration_translates() {
4038        let cfg = ServerConfig {
4039            dpdk_gateway: Some("10.0.0.254".into()),
4040            dpdk_gateway_mac: Some("0c:42:a1:5b:2e:80".into()),
4041            ..Default::default()
4042        };
4043        let dpdk = dpdk_config_from(&cfg).expect("valid config");
4044        assert_eq!(dpdk.gateway_mac, Some([0x0c, 0x42, 0xa1, 0x5b, 0x2e, 0x80]));
4045    }
4046}