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