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