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