Skip to main content

kevy_rt/
runtime.rs

1//! The public entry point: configure and run the thread-per-core server.
2
3use crate::Commands;
4use crate::message::{Inbound, PubSubPatternReg, PubSubReg};
5use crate::shard::Shard;
6use kevy_map::KevyMap;
7use kevy_persist::{Aof, Fsync};
8use kevy_ring::{Consumer, Producer};
9use kevy_store::Store;
10use kevy_sys::{Poller, Waker, tcp_listen_reuseport, waker};
11use std::collections::{HashMap, VecDeque};
12use std::io;
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicBool, AtomicU64};
15use std::sync::{Arc, RwLock};
16
17/// Default slots in each per-core-pair SPSC ring. A full ring spills
18/// to a local backlog (see [`Shard`]), so this only bounds the
19/// lock-free fast path, not capacity. Overridable via the
20/// `[advanced] ring_capacity` config field threaded through
21/// [`Runtime::with_advanced`].
22const DEFAULT_RING_CAPACITY: usize = 1024;
23
24/// The public entry point: configure and run the thread-per-core server.
25pub struct Runtime<C: Commands> {
26    pub(crate) ip: [u8; 4],
27    pub(crate) port: u16,
28    pub(crate) nshards: usize,
29    pub(crate) commands: C,
30    /// Directory for per-shard snapshot files (`dump-<id>.rdb`) and AOF logs.
31    pub(crate) data_dir: PathBuf,
32    /// Whether the append-only log is enabled.
33    pub(crate) enable_aof: bool,
34    /// fsync policy for the AOF. Default `EverySec` matches Redis.
35    pub(crate) appendfsync: Fsync,
36    /// auto-trigger BGREWRITEAOF when AOF grew this many % above the size
37    /// at the previous rewrite. `0` disables. Default `100` (matches Redis).
38    pub(crate) auto_aof_rewrite_pct: u32,
39    /// Floor below which auto-rewrite is skipped. Default `64 MiB`.
40    pub(crate) auto_aof_rewrite_min_size: u64,
41    /// Reactor SPSC ring slot count. See [`DEFAULT_RING_CAPACITY`].
42    pub(crate) ring_capacity: usize,
43    /// Reactor busy-poll iter limit before parking. Stored as `u32`
44    /// for the per-shard counter; the [`Shard`] field carries it
45    /// forward into the loop.
46    pub(crate) spin_limit: u32,
47    /// **v1.30** — `Some(N)` = only shards `0..N` arm accept SQE. `None`
48    /// = every shard accepts (v1.29 byte-identical).
49    pub(crate) accept_shards: Option<usize>,
50    /// **v1.37** — total cap on active client conns. `0` = unlimited.
51    pub(crate) max_clients: usize,
52    /// Reactor blocking-wait timeout in ms when parked.
53    pub(crate) park_timeout_ms: u32,
54    /// Wall-clock-read throttle for the tick check (TTL reaper / live
55    /// config refresh / auto-AOF-rewrite).
56    pub(crate) tick_check_every: u32,
57    /// `[slowlog].slower_than_micros`. Default: `-1` (OFF — zero
58    /// hot-path cost: every command would otherwise pay an
59    /// `Instant::now()` pair around dispatch). Set to `10_000` to match
60    /// Redis's default 10 ms threshold; see [`Self::with_slowlog`] /
61    /// `CONFIG SET slowlog-log-slower-than 10000`.
62    pub(crate) slowlog_slower_than_micros: i64,
63    /// `[slowlog].max_len`. Per-shard cap.
64    pub(crate) slowlog_max_len: u32,
65    /// Single-node cluster mode: slot-based key routing (CRC16 `{hashtag}`
66    /// → contiguous ranges) + one deterministic extra listener per shard at
67    /// `cluster_port_base + id`. `None` = off (default, zero change).
68    pub(crate) cluster_port_base: Option<u16>,
69    /// v3-cluster replication: when `true`, each shard runs a
70    /// `ReplicationSource` with `replication_buffer_size` byte budget;
71    /// every applied mutation is pushed to the backlog. The TCP
72    /// listener + streaming loop arrive in subsequent tasks (T1.12+);
73    /// this batch only wires the producer side. Default `false`.
74    pub(crate) enable_replication: bool,
75    /// v2.3: FEED.* consumer surface. When set, every shard keeps a
76    /// backlog (even with no replicas) and persists the (generation,
77    /// offset) cursor via the feed sidecars.
78    pub(crate) feed_enabled: bool,
79    /// Per-shard backlog byte budget for the feed (`[feed]
80    /// feed_buffer_size`); the effective budget is
81    /// `max(replication_buffer_size, feed_buffer_size)` when both
82    /// features are on (one backlog, two readers).
83    pub(crate) feed_buffer_size: u64,
84    /// Per-shard backlog byte budget when `enable_replication` is set.
85    /// Fed from `[replication] replication_buffer_size`. Default
86    /// `256 MiB` (matches the kevy-config default).
87    pub(crate) replication_buffer_size: u64,
88    /// v3-cluster replication listener: shard `i` binds at
89    /// `replication_port_base + i` (mirrors cluster listener pattern;
90    /// per Issue Ledger I2). `None` = no listener (producer side runs
91    /// without a network surface, backlog accumulates and evicts —
92    /// useful for benchmarks). Default `None`.
93    pub(crate) replication_port_base: Option<u16>,
94    /// Per-shard SlotTable reconnect-window in ms (T1.15). After a
95    /// streaming replica disconnects, its `(replica_id, sent_offset)`
96    /// is recorded in the shard's `slots` map; slots past this age
97    /// are reaped on the next shard tick. Default `60_000` (60 s)
98    /// matches the kevy-config default.
99    pub(crate) replication_reconnect_window_ms: u32,
100    /// Per-shard replica inboxes installed by
101    /// [`Self::with_replica_inboxes`]. Each entry is consumed
102    /// (via `Option::take`) when its shard is constructed, so the
103    /// receiver flows from this Vec to the matching `Shard.replica_inbox`.
104    /// Empty when no replica mode is configured.
105    pub(crate) replica_inboxes: Vec<Option<crate::replica_inbox::ReplicaInboxReceiver>>,
106    /// v1.25 UDS: when `Some(path)`, ALSO bind a Unix-domain stream
107    /// listener at `path` on shard 0 (single global socket, like valkey's
108    /// `unixsocket` config). Lets benches/local clients skip TCP loopback
109    /// overhead. TCP listener stays bound regardless.
110    #[allow(dead_code)] // consumed during run() via take-into-Shard
111    pub(crate) unix_socket_path: Option<PathBuf>,
112}
113
114impl<C: Commands> Runtime<C> {
115    #[must_use]
116    pub fn new(ip: [u8; 4], port: u16, nshards: usize, commands: C) -> Self {
117        Runtime {
118            ip,
119            port,
120            nshards: nshards.max(1),
121            commands,
122            data_dir: PathBuf::from("."),
123            enable_aof: true,
124            appendfsync: Fsync::EverySec,
125            auto_aof_rewrite_pct: 100,
126            auto_aof_rewrite_min_size: 64 * 1024 * 1024,
127            ring_capacity: DEFAULT_RING_CAPACITY,
128            spin_limit: 256,
129            accept_shards: None,
130            max_clients: 10_000,
131            park_timeout_ms: 50,
132            tick_check_every: 256,
133            slowlog_slower_than_micros: -1,
134            slowlog_max_len: 128,
135            cluster_port_base: None,
136            enable_replication: false,
137            feed_enabled: false,
138            feed_buffer_size: 64 * 1024 * 1024,
139            replica_inboxes: Vec::new(),
140            replication_buffer_size: 256 * 1024 * 1024,
141            replication_port_base: None,
142            replication_reconnect_window_ms: 60_000,
143            unix_socket_path: None,
144        }
145    }
146
147
148    /// Spawn one thread per shard and run until `stop` is set.
149    /// v1.25 UDS: also bind a Unix-domain stream listener at `path`. Lets
150    /// local clients (and benchmarks) skip the TCP loopback round-trip.
151    /// Bound on shard 0 only (no SO_REUSEPORT for AF_UNIX, single global
152    /// socket like valkey's `unixsocket` config). TCP listener stays
153    /// bound at the configured `port` regardless.
154    #[must_use]
155    pub fn with_unix_socket(mut self, path: PathBuf) -> Self {
156        self.unix_socket_path = Some(path);
157        self
158    }
159
160    pub fn run(mut self, stop: Arc<AtomicBool>) -> io::Result<()> {
161        let n = self.nshards;
162
163        // v1.25 A.3 (B2: single global bio thread, per
164        // `bench/V125-DECISIONS-PENDING.md`). Spawn BEFORE shards so
165        // every shard's first overwrite already has a live consumer.
166        // The held `bio_send` is moved into the shard loop below
167        // (`store.set_bio_drop_sender`); shutdown ordering is:
168        //   1. shards return → their `Store`s drop → their cloned
169        //      Sender halves drop
170        //   2. this fn's local `bio_send` is dropped here at end of
171        //      scope → channel closes → bio thread's `recv()` returns
172        //      Err → bio thread exits
173        //   3. `bio_handle.join()` blocks until that exit so a final
174        //      large free isn't truncated by process tear-down
175        // (`madvise` returning the page to the kernel still needs the
176        // process alive). See `crate::bio` for the full rationale.
177        let (bio_send, bio_handle) = crate::bio::spawn();
178
179        // Cluster binds shard `i` at `port_base + i`; reject a range that
180        // overflows u16 up front (loud) instead of wrapping a listener onto
181        // a low/privileged port while CLUSTER SLOTS advertises 65536+.
182        if let Some(base) = self.cluster_port_base
183            && base as usize + n > u16::MAX as usize + 1
184        {
185            return Err(io::Error::new(
186                io::ErrorKind::InvalidInput,
187                format!(
188                    "cluster port range {base}..={} exceeds 65535 ({n} shards)",
189                    base as usize + n - 1
190                ),
191            ));
192        }
193
194        // Same overflow check for the replication port range
195        // (`base + 0 .. base + n`). See Issue Ledger I2 for the
196        // per-shard listener decision.
197        if let Some(base) = self.replication_port_base
198            && base as usize + n > u16::MAX as usize + 1
199        {
200            return Err(io::Error::new(
201                io::ErrorKind::InvalidInput,
202                format!(
203                    "replication port range {base}..={} exceeds 65535 ({n} shards)",
204                    base as usize + n - 1
205                ),
206            ));
207        }
208
209        // One lock-free SPSC ring per ordered core-pair (i→j): the producer goes
210        // to shard i's outbox[j], the consumer to shard j's inbox[i]. There is no
211        // self-ring — a shard runs its own commands inline, never over a ring.
212        let mut outboxes: Vec<Vec<Option<Producer<Inbound>>>> =
213            (0..n).map(|_| (0..n).map(|_| None).collect()).collect();
214        let mut inboxes: Vec<Vec<Option<Consumer<Inbound>>>> =
215            (0..n).map(|_| (0..n).map(|_| None).collect()).collect();
216        for i in 0..n {
217            for j in 0..n {
218                if i == j {
219                    continue;
220                }
221                let (p, c) = kevy_ring::ring::<Inbound>(self.ring_capacity);
222                outboxes[i][j] = Some(p);
223                inboxes[j][i] = Some(c);
224            }
225        }
226
227        let mut wakers: Vec<Arc<Waker>> = Vec::with_capacity(n);
228        for _ in 0..n {
229            wakers.push(Arc::new(waker()?));
230        }
231        let parked: Vec<Arc<crate::shard::CachePadded<AtomicBool>>> = (0..n)
232            .map(|_| Arc::new(crate::shard::CachePadded::new(AtomicBool::new(false))))
233            .collect();
234        // Per-shard inbox-dirty bitmaps (one u64 bit per peer src).
235        // Senders OR a bit on the target's dirty word; the target's
236        // `drain_inbound_core` swaps and short-circuits when 0.
237        assert!(
238            n <= 64,
239            "kevy-rt: shard count {n} exceeds 64 — inbound_dirty bitmap holds one bit per peer in a u64. Reduce --threads or extend to a multi-word bitmap.",
240        );
241        // A2 (2026-06-20): pad each Arc<AtomicU64> to a full 64-byte cache
242        // line. H1 c2c diagnostic showed cross-shard fetch_or vs. owner
243        // swap on adjacent atomics bounced cache lines between cores.
244        let inbound_dirty: Vec<Arc<crate::shard::CachePadded<AtomicU64>>> = (0..n)
245            .map(|_| Arc::new(crate::shard::CachePadded::new(AtomicU64::new(0))))
246            .collect();
247
248        // Shared pub/sub channel registry (one per server, read on every PUBLISH).
249        let pubsub: PubSubReg = Arc::new(RwLock::new(HashMap::new()));
250        // Shared pub/sub pattern registry. Empty in steady state — the
251        // channel-only PUBLISH path skips the walk when so.
252        let pubsub_patterns: PubSubPatternReg = Arc::new(RwLock::new(Vec::new()));
253
254        // Reconcile the on-disk shard layout (count + routing) before any
255        // shard loads its files; a mismatch re-homes every key once, here.
256        // Skipped for a pure in-memory run against a dir with no kevy files.
257        // Cluster mode always records the layout even with AOF off and an
258        // empty dir: a later SAVE writes slot-distributed `dump-{i}.rdb`, and
259        // without a meta a non-cluster restart would read them as KevyHash
260        // and silently strand every key.
261        if self.enable_aof
262            || self.cluster_port_base.is_some()
263            || crate::reshard::has_kevy_files(&self.data_dir)
264        {
265            let routing = if self.cluster_port_base.is_some() {
266                kevy_persist::Routing::Slots
267            } else {
268                kevy_persist::Routing::KevyHash
269            };
270            crate::reshard::ensure_layout(&self.data_dir, n, routing, &self.commands)?;
271        }
272
273        // Advertised cluster topology (None = cluster off). A 0.0.0.0 bind
274        // advertises 127.0.0.1 — an unroutable redirect target would strand
275        // every cluster client (single-machine scope; no announce-ip knob).
276        let topo = self.cluster_port_base.map(|base| crate::cluster::ClusterTopo {
277            ip: if self.ip == [0, 0, 0, 0] { [127, 0, 0, 1] } else { self.ip },
278            port_base: base,
279        });
280
281        // Build every shard up front so a bind/open failure aborts before we spawn.
282        let mut shards = Vec::with_capacity(n);
283        // UDS listener: only ONE per server (no SO_REUSEPORT for AF_UNIX), so
284        // it lives on shard 0. Bound up-front so a bind failure aborts before
285        // any shard spawns.
286        let mut unix_listener: Option<kevy_sys::Socket> = None;
287        if let Some(p) = self.unix_socket_path.as_ref() {
288            let path_bytes = p.to_string_lossy();
289            unix_listener = Some(kevy_sys::unix_listen(path_bytes.as_bytes(), 1024)?);
290        }
291        for id in 0..n {
292            let arms_accept = self.accept_shards.is_none_or(|k| id < k);
293            // v1.30 — off-accept-set shards skip the SO_REUSEPORT bind so
294            // the kernel routes new conns only to the armed subset.
295            let listener = if arms_accept {
296                Some(tcp_listen_reuseport(self.ip, self.port, 1024)?)
297            } else {
298                None
299            };
300            // Cluster mode: a second, deterministic per-shard listener at
301            // port_base + id (plain bind — exactly one owner per port).
302            let cluster_listener = match self.cluster_port_base {
303                Some(base) => Some(kevy_sys::tcp_listen(self.ip, base + id as u16, 1024)?),
304                None => None,
305            };
306            // Replication listener (per Issue Ledger I2): per-shard
307            // deterministic port, same `tcp_listen` (no SO_REUSEPORT)
308            // pattern as cluster. A replica's shard-aware client will
309            // connect to every `base + id` to mirror the full keyspace.
310            let replication_listener = match self.replication_port_base {
311                Some(base) => Some(kevy_sys::tcp_listen(self.ip, base + id as u16, 1024)?),
312                None => None,
313            };
314            let aof = if self.enable_aof {
315                Some(Aof::open(
316                    &kevy_persist::layout::aof_path(&self.data_dir, id),
317                    self.appendfsync,
318                )?)
319            } else {
320                None
321            };
322            let mut store = Store::new();
323            // The reactor loop refreshes the store clock once per batch, so
324            // lazy expiry can trust the cached clock (skip per-command
325            // `Instant::now()`).
326            store.set_cached_clock(true);
327            // v1.25 A.3: hand the bio-drop channel sender to the store so
328            // SET overwrites of heavy values (Arc<[u8]> ≥ 256 B, non-empty
329            // collections) get freed off-reactor. Sender clone is cheap
330            // (`Arc::clone`); the bio thread is shared across all shards
331            // (B2 single-global, mirrors valkey `bio.c`).
332            store.set_bio_drop_sender(bio_send.clone());
333            self.commands.on_shard_init(&mut store);
334            shards.push(Shard {
335                xshard_inflight: 0,
336                id,
337                nshards: n,
338                cluster: topo.clone(),
339                cluster_listener,
340                // UDS: only shard 0 holds the (single) unix listener.
341                unix_listener: if id == 0 { unix_listener.take() } else { None },
342                store,
343                commands: self.commands.clone(),
344                poller: Poller::new()?,
345                listener,
346                waker: wakers[id].clone(),
347                inboxes: std::mem::take(&mut inboxes[id]),
348                outboxes: std::mem::take(&mut outboxes[id]),
349                backlog: (0..n).map(|_| VecDeque::new()).collect(),
350                wakers: wakers.clone(),
351                conns: KevyMap::new(),
352                arm_pending: Vec::new(),
353                closing_uring_conns: Vec::new(),
354                fd_to_conn: KevyMap::new(),
355                next_conn_id: 1,
356                events: Vec::with_capacity(1024),
357                read_buf: vec![0u8; 64 * 1024],
358                pending_wakes: 0,
359                backlog_nonempty: 0,
360                request_batch_nonempty: 0,
361                publish_batch_nonempty: 0,
362                parked: parked.clone(),
363                inbound_dirty: inbound_dirty.clone(),
364                data_dir: self.data_dir.clone(),
365                aof,
366                replicate: if self.enable_replication || self.feed_enabled {
367                    let budget = if self.feed_enabled {
368                        self.replication_buffer_size.max(self.feed_buffer_size)
369                    } else {
370                        self.replication_buffer_size
371                    };
372                    let boot = kevy_persist::feed_meta::load_feed_boot(&self.data_dir, id)?;
373                    let mut src = kevy_replicate::source::ReplicationSource::new(
374                        usize::try_from(budget).unwrap_or(usize::MAX),
375                    );
376                    src.set_next_offset(boot.next_offset);
377                    Some(kevy_replicate::feed::FeedSource::new(boot.generation, src))
378                } else {
379                    None
380                },
381                replication_listener,
382                replicas: Vec::new(),
383                slots: kevy_replicate::slot::SlotTable::new(),
384                replication_reconnect_window_ms: self.replication_reconnect_window_ms,
385                replication_epoch: std::time::Instant::now(),
386                replica_inbox: self.replica_inboxes.get_mut(id).and_then(Option::take),
387                replica_snapshot_buf: Vec::new(),
388                replica_applied_next: 0,
389                repl_waiters: Vec::new(),
390                seen_promotion_epoch: None,
391                persist: crate::persist_worker::PersistWorker::new(),
392                auto_aof_rewrite_pct: self.auto_aof_rewrite_pct,
393                auto_aof_rewrite_min_size: self.auto_aof_rewrite_min_size,
394                dirty: Vec::new(),
395                pubsub: pubsub.clone(),
396                pubsub_patterns: pubsub_patterns.clone(),
397                psub_local: HashMap::new(),
398                subs_by_channel: HashMap::new(),
399                publish_batch: (0..n).map(|_| Vec::new()).collect(),
400                request_batch: (0..n).map(|_| Vec::new()).collect(),
401                // Seed from the live config at construction, not default():
402                // these flags were otherwise blind until the first 100 ms
403                // shard tick, so a write landing before that never fired
404                // its keyspace notification (CI-visible flake; a real
405                // startup gap for any pre-configured notify_keyspace_events).
406                notify_flags: self
407                    .commands
408                    .live_runtime_config()
409                    .notify_flags
410                    .unwrap_or_default(),
411                spin_limit: self.spin_limit,
412                arms_accept: self.accept_shards.is_none_or(|n| id < n),
413                max_clients_per_shard: if self.max_clients == 0 {
414                    0
415                } else {
416                    self.max_clients.div_ceil(n)
417                },
418                rejected_connections: 0,
419                // `Poller::wait` takes the timeout as `i32` (POSIX
420                // poll/epoll convention). The config knob is `u32` —
421                // we clamp to i32::MAX, far above any sane park-timeout.
422                park_timeout_ms: self.park_timeout_ms.min(i32::MAX as u32) as i32,
423                tick_check_every: self.tick_check_every,
424                slowlog: crate::exec_slowlog::SlowlogState::new(
425                    self.slowlog_slower_than_micros,
426                    self.slowlog_max_len,
427                ),
428                blocked: crate::blocked::BlockedClients::new(),
429                origin_blocks: std::collections::HashMap::new(),
430                xwaiters: crate::block_xshard::XShardWaiters::default(),
431                reply_scratch: Vec::with_capacity(4096),
432                argv_pool: kevy_resp::ArgvPool::new(),
433            });
434        }
435
436        // Reactor selection on Linux:
437        //   KEVY_IO_URING unset → auto: try io_uring, fall back to epoll if the
438        //     host can't build the ring (probe below) — startup never fails.
439        //   KEVY_IO_URING=0/off/no/false → force the epoll readiness reactor.
440        //   KEVY_IO_URING=<anything else> → force io_uring (no fallback; a
441        //     setup failure then surfaces loudly — for benchmarks / tests).
442        // The probe creates+drops a real ring with the run_uring parameters, so
443        // it catches a seccomp-blocked io_uring_setup (Docker's default profile)
444        // and pre-5.19 kernels before any shard loads data. (macOS = kqueue.)
445        #[cfg(target_os = "linux")]
446        let (use_uring, uring_forced) = match std::env::var("KEVY_IO_URING").ok().as_deref() {
447            Some("0") | Some("off") | Some("no") | Some("false") => (false, true),
448            Some(_) => (true, true),
449            None => {
450                let avail = crate::uring_reactor::io_uring_available();
451                eprintln!(
452                    "kevy: reactor = {} (io_uring {})",
453                    if avail { "io_uring" } else { "epoll" },
454                    if avail { "available" } else { "unavailable — kernel <5.19 or seccomp; using epoll" },
455                );
456                (avail, false)
457            }
458        };
459
460        // v1.18.0: the replication listener + accept path is wired only
461        // through the epoll/kqueue reactor (`shard.run`); the io_uring
462        // T1.12.5: io_uring + replication is now supported. The
463        // replication-adjacent work (accept / read / write / pump /
464        // slot+view+watermark ticks) is poll-driven from the io_uring
465        // reactor's tick path (mostly per-tick @ 10 Hz, with
466        // `pump_replication` + `reap_closed_replicas` per-iter via
467        // their own early returns when nothing's live). Throughput
468        // path stays io_uring-native — only replica metadata uses
469        // polling. See `Shard::run_uring`.
470
471        let mut handles = Vec::with_capacity(n);
472        for shard in shards {
473            let stop = stop.clone();
474            let id = shard.id;
475            handles.push(std::thread::spawn(move || {
476                // v2.1.1: per-shard ring setup is attempted BEFORE
477                // committing to the io_uring path. The global probe
478                // proves one ring builds; N shards need N rings, and a
479                // late failure (ENOMEM under pressure) used to kill the
480                // shard thread and leave a half-dead server (found via
481                // GH-runner CI: blocking_cross_shard hangs). Auto mode
482                // now degrades that shard to epoll, loudly. A forced
483                // KEVY_IO_URING=1 keeps the old fail-loud contract.
484                #[cfg(target_os = "linux")]
485                let res = if use_uring {
486                    match crate::uring_reactor::build_uring() {
487                        Ok(pair) => shard.run_uring(pair, stop),
488                        Err(e) if !uring_forced => {
489                            eprintln!(
490                                "kevy: shard {id}: io_uring setup failed ({e}); \
491                                 falling back to the epoll reactor for this shard"
492                            );
493                            shard.run(stop)
494                        }
495                        Err(e) => Err(e),
496                    }
497                } else {
498                    shard.run(stop)
499                };
500                #[cfg(not(target_os = "linux"))]
501                let res = shard.run(stop);
502                if let Err(e) = res {
503                    eprintln!("kevy: shard {id} exited with error: {e}");
504                }
505            }));
506        }
507        for h in handles {
508            let _ = h.join();
509        }
510        // v1.25 A.3 shutdown: every shard has joined → every cloned
511        // sender on every Store has been dropped. Drop the last live
512        // sender (this fn's `bio_send`) so the channel closes; the bio
513        // thread's `recv()` returns Err and it exits its loop. The
514        // `join()` then blocks until that exit completes — guarding
515        // against process tear-down while a final large free is in
516        // flight (an unsafe wrt `madvise`/`munmap` semantics — the
517        // kernel needs the process alive to actually release pages).
518        drop(bio_send);
519        let _ = bio_handle.join();
520        Ok(())
521    }
522}