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