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