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