{"version":"https://jsonfeed.org/version/1.1","title":"RustHacks","home_page_url":"https://rusthacks.dev","feed_url":"https://rusthacks.dev/feed.json","description":"Practical guides and hacks for Rust developers.","language":"en","authors":[{"name":"RustHacks","url":"https://rusthacks.dev"}],"items":[{"id":"https://rusthacks.dev/library/rust-backpressure-design","url":"https://rusthacks.dev/library/rust-backpressure-design","title":"Backpressure in Rust Pipelines and Services Explained","summary":"Backpressure is how a slow consumer tells a fast producer to slow down. Without it, queues grow until memory runs out. Rust's bounded channels and semaphores make it explicit.","content_html":"<p>A system is a set of stages connected by queues: accept requests, parse, process, write. If any stage is slower than the one feeding it, the queue in front of it grows. With unbounded queues it grows until the process is killed. Backpressure is the mechanism that connects the queue's fullness back to the producer. In Rust it is mostly a matter of choosing bounded types. Versions: Rust 1.90, Tokio 1.47, <code>futures</code> 0.3.</p>\n<h2>The failure without it</h2>\n<pre><code class=\"language-rust\">use tokio::sync::mpsc;\n\n#[tokio::main]\nasync fn main() {\n    let (tx, mut rx) = mpsc::unbounded_channel::&lt;Vec&lt;u8&gt;&gt;();\n\n    tokio::spawn(async move {\n        loop {\n            // producer: no pacing, produces as fast as it can\n            if tx.send(vec![0u8; 1024]).is_err() {\n                break;\n            }\n            tokio::task::yield_now().await;\n        }\n    });\n\n    while let Some(item) = rx.recv().await {\n        // consumer: slower than the producer\n        tokio::time::sleep(std::time::Duration::from_millis(1)).await;\n        drop(item);\n    }\n}</code></pre>\n<p>The producer runs at thousands of items per second and the consumer at about a thousand. The difference accumulates in the channel at megabytes per second, with no error until the allocator fails. The program has no way to express \"I am full\".</p>\n<h2>Bounded channel: the basic form</h2>\n<pre><code class=\"language-rust\">use tokio::sync::mpsc;\n\n#[tokio::main]\nasync fn main() {\n    let (tx, mut rx) = mpsc::channel::&lt;Vec&lt;u8&gt;&gt;(100);\n\n    let producer = tokio::spawn(async move {\n        for i in 0..10_000 {\n            // waits here when 100 items are queued: this IS the backpressure\n            if tx.send(vec![i as u8; 1024]).await.is_err() {\n                break;\n            }\n        }\n    });\n\n    while let Some(item) = rx.recv().await {\n        tokio::time::sleep(std::time::Duration::from_millis(1)).await;\n        drop(item);\n    }\n    producer.await.unwrap();\n}</code></pre>\n<p>When the queue holds 100 items, <code>send().await</code> suspends the producer until the consumer takes one. Memory is bounded by 100 items, and the producer's speed matches the consumer's. The same applies across a pipeline: each stage uses a bounded channel to the next, and pressure propagates upstream to the source (<a href=\"/library/tokio-backpressure-bounded-channels\">bounded channels in Tokio</a>).</p>\n<h2>Choosing the full-queue policy</h2>\n<p>When the queue is full, something has to give:</p>\n<p>| Policy | API | Use when | |---|---|---| | Wait | <code>send().await</code> | the producer can pause (internal pipelines, file readers) | | Reject newest | <code>try_send</code> returning <code>Full</code> | you can tell the caller to retry (HTTP: 503) | | Drop oldest | ring buffer, <code>broadcast</code> lag | only fresh data matters (metrics, telemetry) | | Drop newest | <code>try_send</code> and ignore | best-effort logging | | Error | <code>try_send</code> and propagate | upstream must know |</p>\n<pre><code class=\"language-rust\">use tokio::sync::mpsc::error::TrySendError;\n\nfn enqueue(tx: &amp;mpsc::Sender&lt;Job&gt;, job: Job) -&gt; Result&lt;(), &amp;'static str&gt; {\n    match tx.try_send(job) {\n        Ok(()) =&gt; Ok(()),\n        Err(TrySendError::Full(_)) =&gt; Err(\"busy\"),       // caller returns 503 with Retry-After\n        Err(TrySendError::Closed(_)) =&gt; Err(\"shutting down\"),\n    }\n}</code></pre>\n<p>In a web handler, waiting on a full queue holds the request open and ties up resources, so reject quickly (<code>try_send</code>) and let the client back off (<a href=\"/library/rust-graceful-degradation\">graceful degradation</a>).</p>\n<h2>Limit in-flight work with a semaphore</h2>\n<p>Backpressure also applies to spawning tasks. A loop that <code>tokio::spawn</code>s per incoming item has no bound:</p>\n<pre><code class=\"language-rust\">use std::sync::Arc;\nuse tokio::sync::Semaphore;\n\nasync fn process_all(items: Vec&lt;u64&gt;) {\n    let permits = Arc::new(Semaphore::new(32));\n    let mut handles = Vec::new();\n    for item in items {\n        // waits here when 32 tasks are running\n        let permit = Arc::clone(&amp;permits).acquire_owned().await.unwrap();\n        handles.push(tokio::spawn(async move {\n            do_work(item).await;\n            drop(permit);\n        }));\n    }\n    for h in handles {\n        let _ = h.await;\n    }\n}\n\nasync fn do_work(_n: u64) {}</code></pre>\n<p>Acquiring the permit before spawning is the key: the loop itself is paced. See <a href=\"/library/tokio-semaphore-limit-concurrency\">semaphores</a>.</p>\n<h2>Streams: buffer_unordered</h2>\n<pre><code class=\"language-rust\">use futures::{stream, StreamExt};\n\nasync fn fetch(url: String) -&gt; usize {\n    url.len()\n}\n\n#[tokio::main]\nasync fn main() {\n    let urls: Vec&lt;String&gt; = (0..1000).map(|i| format!(\"https://example.test/{i}\")).collect();\n    let total: usize = stream::iter(urls)\n        .map(fetch)\n        .buffer_unordered(16)          // at most 16 futures in flight\n        .fold(0, |acc, n| async move { acc + n })\n        .await;\n    println!(\"{total}\");\n}</code></pre>\n<p><code>buffer_unordered(16)</code> polls the source only when fewer than 16 futures are running, so a lazy source is read at the pace of completion (<a href=\"/library/rust-buffer-unordered\">buffer_unordered</a>, <a href=\"/library/rust-async-streams\">async streams</a>). The stream is pull-based, which gives backpressure by construction: nothing is produced until the consumer asks.</p>\n<h2>At the network edge</h2>\n<p>TCP has flow control. If your handler stops reading a socket, the kernel buffer fills, the advertised window shrinks and the sender slows down. This only works if your code actually stops reading: an accept loop that reads requests into an unbounded queue defeats it. Read, process, then read again, with a bound in between. For the accept loop itself, acquire a permit before <code>accept()</code> so the kernel's listen backlog absorbs overflow (<a href=\"/library/tokio-tcp-listener-stream\">async TCP with Tokio</a>). HTTP/2 has per-stream and per-connection windows with the same effect.</p>\n<h2>Detecting missing backpressure</h2>\n<ul><li>Memory that grows steadily under load and recovers after traffic stops.</li><li>Latency that rises over time at a constant request rate (queue growth).</li><li>A metric of queue length that only goes up. Export <code>tx.capacity()</code> and <code>tx.max_capacity()</code> from channels (<a href=\"/library/tokio-runtime-metrics\">Tokio runtime metrics</a>).</li><li><code>unbounded_channel</code> and <code>spawn</code> inside loops in code review.</li></ul>\n<h2>Design checklist</h2>\n<ol><li>Every queue is bounded, and the bound is a number someone chose.</li><li>Every stage has a stated policy for full.</li><li>The source can be slowed or rejected (client gets 503 or 429, file reader waits).</li><li>In-flight work has a cap.</li><li>Timeouts exist, so a stuck consumer does not freeze the pipeline forever.</li><li>Tests simulate a slow consumer and assert memory or queue length stays bounded.</li></ol>\n<p>Related patterns: <a href=\"/library/rust-fan-out-fan-in-pipelines\">fan-out and fan-in pipelines</a>, <a href=\"/library/rust-in-process-event-bus\">an in-process event bus</a> and <a href=\"/library/rust-actor-model-tokio\">the actor model</a>.</p>","date_published":"2026-11-03T13:32:00+00:00","tags":["Architecture"]},{"id":"https://rusthacks.dev/library/rust-graceful-degradation","url":"https://rusthacks.dev/library/rust-graceful-degradation","title":"Graceful Degradation in Rust Services Under Heavy Load","summary":"A service that stays up by doing less beats one that tries to do everything and falls over. Decide in advance what to drop and how to say no quickly.","content_html":"<p>Services do not fail at the load they were designed for. They fail at 1.5 times that, when queues grow, latencies stretch, clients time out and retry, and the retries make it worse. A service that degrades gracefully keeps serving a useful share of traffic during the spike and recovers on its own afterward. The techniques are standard, and the work is deciding the policy beforehand. Versions: Rust 1.90, Axum 0.8, Tower 0.5, <code>tower-http</code> 0.6.</p>\n<h2>How overload kills a service</h2>\n<p>Suppose a handler takes 50 ms and the service has capacity for 200 requests per second. At 250 requests per second, the extra 50 queue up each second. The queue grows without bound, so the wait grows, so by the time a request is processed the client has given up and sent a retry, work is done for nobody, and goodput (useful work) falls to near zero while the CPU is at 100 percent. The fix is to stop accepting what you cannot finish.</p>\n<h2>Bound concurrency and shed the rest</h2>\n<pre><code class=\"language-rust\">use axum::{error_handling::HandleErrorLayer, http::StatusCode, routing::get, BoxError, Router};\nuse std::time::Duration;\nuse tower::{limit::ConcurrencyLimitLayer, load_shed::LoadShedLayer, timeout::TimeoutLayer, ServiceBuilder};\n\nasync fn handle_overload(err: BoxError) -&gt; (StatusCode, [(&amp;'static str, &amp;'static str); 1], &amp;'static str) {\n    if err.is::&lt;tower::load_shed::error::Overloaded&gt;() {\n        (StatusCode::SERVICE_UNAVAILABLE, [(\"retry-after\", \"2\")], \"busy, retry shortly\")\n    } else if err.is::&lt;tower::timeout::error::Elapsed&gt;() {\n        (StatusCode::GATEWAY_TIMEOUT, [(\"retry-after\", \"5\")], \"request timed out\")\n    } else {\n        (StatusCode::INTERNAL_SERVER_ERROR, [(\"retry-after\", \"5\")], \"internal error\")\n    }\n}\n\npub fn app() -&gt; Router {\n    let protect = ServiceBuilder::new()\n        .layer(HandleErrorLayer::new(handle_overload))\n        .layer(LoadShedLayer::new())\n        .layer(ConcurrencyLimitLayer::new(256))\n        .layer(TimeoutLayer::new(Duration::from_secs(5)));\n\n    Router::new().route(\"/search\", get(search)).layer(protect)\n}\n\nasync fn search() -&gt; &amp;'static str {\n    tokio::time::sleep(Duration::from_millis(50)).await;\n    \"results\"\n}</code></pre>\n<p><code>ConcurrencyLimitLayer</code> allows 256 requests in flight. Without <code>LoadShedLayer</code>, the 257th request waits for a permit, which is a queue; with it, the layer returns <code>Overloaded</code> immediately when the inner service is not ready, which the error handler turns into a <code>503</code> with <code>Retry-After</code>. Layers are applied from the outside in order listed: error handling outermost, then shedding, then concurrency, then timeout around the handler. The timeout bounds the time a single slow request holds a permit. Note that the stock concurrency limit protects the whole router. Per-route limits (expensive search versus cheap health check) need separate layers on separate routes.</p>\n<h2>Timeouts everywhere</h2>\n<p>Every call crossing a boundary needs a deadline: incoming requests, database queries, outgoing HTTP calls. Budget them so inner timeouts are shorter than outer ones, or the outer gives up while the inner keeps working (<a href=\"/library/axum-timeouts-limits\">Axum timeouts and limits</a>, <a href=\"/library/tokio-timeouts\">Tokio timeouts</a>). Propagate the remaining deadline if you can, so downstream services stop working on abandoned requests.</p>\n<h2>Fallbacks and stale data</h2>\n<p>When a dependency is slow, serve something rather than fail:</p>\n<pre><code class=\"language-rust\">use std::time::Duration;\nuse tokio::time::timeout;\n\nasync fn recommendations(user_id: u64, cache: &amp;Cache) -&gt; Vec&lt;String&gt; {\n    match timeout(Duration::from_millis(200), fetch_recommendations(user_id)).await {\n        Ok(Ok(items)) =&gt; {\n            cache.store(user_id, items.clone());\n            items\n        }\n        _ =&gt; cache.get_stale(user_id).unwrap_or_else(popular_items),\n    }\n}</code></pre>\n<p>Ladder of fallbacks: fresh result, stale cached result, generic default, omit the feature. Stale-while-revalidate does this systematically (<a href=\"/library/rust-stale-while-revalidate\">stale while revalidate</a>). A circuit breaker avoids hammering a failing dependency while it recovers (<a href=\"/library/rust-circuit-breakers-bulkheads\">circuit breakers and bulkheads</a>).</p>\n<h2>Priorities</h2>\n<p>Not all requests are equal. Health checks must always answer. Writes that complete a purchase matter more than a recommendations widget. Options:</p>\n<ul><li><strong>Separate concurrency limits per route group</strong>, so low-priority traffic cannot consume all permits.</li><li><strong>Separate listeners or pools</strong> for critical paths and background work.</li><li><strong>Bulkheads</strong>: a dedicated connection pool for the critical database operations (<a href=\"/library/rust-database-timeouts-retries\">database timeouts and retries</a>).</li><li><strong>Shed by class</strong>: reject anonymous traffic first, then free tier, then paid.</li></ul>\n<pre><code class=\"language-rust\">let critical = Router::new().route(\"/checkout\", post(checkout)).layer(ConcurrencyLimitLayer::new(128));\nlet optional = Router::new().route(\"/recommendations\", get(recs)).layer(ConcurrencyLimitLayer::new(32));\nlet app = Router::new().merge(critical).merge(optional);</code></pre>\n<p>Each group has its own permit pool, so a flood of recommendation requests cannot starve checkout.</p>\n<h2>Degrade features on purpose</h2>\n<p>Under pressure, switch off the expensive extras: fancy sorting, personalization, large page sizes, synchronous analytics. A flag toggled automatically (when queue depth or latency crosses a threshold) or by an operator makes it a lever rather than a code change (<a href=\"/library/rust-feature-flags-services\">feature flags</a>). Return smaller pages, drop optional fields, skip enrichment calls. Document these modes and test them.</p>\n<h2>Measure overload</h2>\n<ul><li>Requests in flight, queue depth, p50/p95/p99 latency and rejection rate per route.</li><li>Goodput: successful responses per second.</li><li>Saturation signals: worker busy ratio, connection pool wait time (<a href=\"/library/tokio-runtime-metrics\">Tokio runtime metrics</a>, <a href=\"/library/rust-tail-latency-percentiles\">tail latency</a>).</li></ul>\n<p>Alert on rising shed counts and on latency, and not on CPU alone.</p>\n<h2>Clients matter too</h2>\n<p>A <code>503</code> with <code>Retry-After</code> works only if clients respect it. Retries need backoff, jitter and budgets, or they amplify the overload (<a href=\"/library/rust-async-resilience-patterns\">async resilience patterns</a>). Document the behavior for your API consumers.</p>\n<h2>Test it</h2>\n<p>Run a load test above capacity and check the shape of the curve: latency for accepted requests should stay bounded, rejection rate should rise, and recovery after the spike should be quick (<a href=\"/library/load-test-rust-service\">load testing</a>). Also kill a dependency during the test, and verify fallbacks engage. Related design: <a href=\"/library/rust-backpressure-design\">backpressure in pipelines</a>, <a href=\"/library/axum-rate-limiting\">Axum rate limiting</a> and <a href=\"/library/rust-service-performance-checklist\">service performance checklist</a>.</p>","date_published":"2026-11-03T13:30:00+00:00","tags":["Architecture"]},{"id":"https://rusthacks.dev/library/rust-distributed-rate-limiting","url":"https://rusthacks.dev/library/rust-distributed-rate-limiting","title":"Distributed Rate Limiting for Multi Instance Rust Services","summary":"In-process limiters count per instance, so ten replicas allow ten times the limit. A shared counter in Redis makes the limit global, at the cost of a network hop and a failure policy.","content_html":"<p>A rate limiter in each instance is easy and wrong for a fleet. With a limit of 100 requests per minute and ten replicas behind a load balancer, a client can make about a thousand. If the limit protects a downstream system or enforces a contract, it has to be global. That requires shared state, and the shared state brings atomicity, latency and failure questions. Versions: Rust 1.90, <code>redis</code> 0.32, Axum 0.8, Redis 7.4. The single-process version is in <a href=\"/library/rust-rate-limiter-token-bucket\">building a rate limiter</a>.</p>\n<h2>Fixed window with INCR</h2>\n<p>The simplest shared limiter: one counter per key per window.</p>\n<pre><code class=\"language-rust\">use redis::{aio::ConnectionManager, AsyncCommands};\n\npub struct Decision {\n    pub allowed: bool,\n    pub remaining: u32,\n    pub retry_after_secs: u64,\n}\n\npub async fn fixed_window(\n    redis: &amp;mut ConnectionManager,\n    key: &amp;str,\n    limit: u32,\n    window_secs: u64,\n) -&gt; redis::RedisResult&lt;Decision&gt; {\n    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();\n    let window = now / window_secs;\n    let redis_key = format!(\"rl:{key}:{window}\");\n\n    let count: u32 = redis.incr(&amp;redis_key, 1).await?;\n    if count == 1 {\n        let _: () = redis.expire(&amp;redis_key, window_secs as i64 + 1).await?;\n    }\n    Ok(Decision {\n        allowed: count &lt;= limit,\n        remaining: limit.saturating_sub(count),\n        retry_after_secs: window_secs - (now % window_secs),\n    })\n}</code></pre>\n<p><code>INCR</code> is atomic, so concurrent instances cannot lose updates. There is a small gap: if the process dies between <code>INCR</code> and <code>EXPIRE</code>, the key never expires, which is why the expiry is set when <code>count == 1</code> and why production code uses a script or <code>SET key 0 EX n NX</code> first. The known weakness of fixed windows: a client can send <code>limit</code> requests at the end of one window and <code>limit</code> more at the start of the next, doubling the rate for a moment.</p>\n<h2>Atomic token bucket in Lua</h2>\n<p>A Lua script runs atomically inside Redis, so reading and updating the bucket cannot interleave with another instance:</p>\n<pre><code class=\"language-rust\">const TOKEN_BUCKET: &amp;str = r#\"\nlocal key      = KEYS[1]\nlocal capacity = tonumber(ARGV[1])\nlocal rate     = tonumber(ARGV[2])   -- tokens per second\nlocal now_ms   = tonumber(ARGV[3])\nlocal cost     = tonumber(ARGV[4])\n\nlocal data   = redis.call('HMGET', key, 'tokens', 'ts')\nlocal tokens = tonumber(data[1])\nlocal ts     = tonumber(data[2])\nif tokens == nil then\n  tokens = capacity\n  ts = now_ms\nend\n\nlocal elapsed = math.max(0, now_ms - ts) / 1000.0\ntokens = math.min(capacity, tokens + elapsed * rate)\n\nlocal allowed = 0\nif tokens &gt;= cost then\n  tokens = tokens - cost\n  allowed = 1\nend\n\nredis.call('HSET', key, 'tokens', tokens, 'ts', now_ms)\nredis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)\n\nlocal retry_ms = 0\nif allowed == 0 then\n  retry_ms = math.ceil((cost - tokens) / rate * 1000)\nend\nreturn { allowed, math.floor(tokens), retry_ms }\n\"#;\n\npub async fn token_bucket(\n    redis: &amp;mut ConnectionManager,\n    key: &amp;str,\n    capacity: u32,\n    per_second: f64,\n) -&gt; redis::RedisResult&lt;Decision&gt; {\n    let now_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;\n    let (allowed, remaining, retry_ms): (i32, i64, i64) = redis::Script::new(TOKEN_BUCKET)\n        .key(format!(\"rl:tb:{key}\"))\n        .arg(capacity)\n        .arg(per_second)\n        .arg(now_ms)\n        .arg(1)\n        .invoke_async(redis)\n        .await?;\n    Ok(Decision {\n        allowed: allowed == 1,\n        remaining: remaining.max(0) as u32,\n        retry_after_secs: (retry_ms as u64).div_ceil(1000),\n    })\n}</code></pre>\n<p>The script uses the caller's clock for <code>now_ms</code>. Instances with skewed clocks distort refill, so either keep clocks synchronized (NTP) or use <code>redis.call('TIME')</code> inside the script as the single clock. <code>redis::Script</code> sends the script by hash after the first call. The key expires when the bucket would be full again, so idle clients cost no memory.</p>\n<h2>Choosing an algorithm</h2>\n<p>| Algorithm | Accuracy | Memory per key | Bursts | Notes | |---|---|---|---|---| | Fixed window | boundary spikes up to 2x | one counter | at window start | simplest | | Sliding window log | exact | one entry per request | no | sorted set; costly for large limits | | Sliding window counter | close | two counters | limited | weighted previous window | | Token bucket | exact rate | two fields | up to capacity | most flexible | | Leaky bucket | smooths output | queue | no | for shaping, not rejecting |</p>\n<p>For API limits, token bucket or sliding window counter are the usual choices.</p>\n<h2>Keys</h2>\n<p>Pick the identity carefully: authenticated user or API key where available, IP otherwise (behind a proxy, from the trusted forwarded header; see <a href=\"/library/axum-behind-reverse-proxy\">behind a reverse proxy</a>). Combine dimensions with tiers: <code>per_ip:1.2.3.4</code>, <code>per_key:abc</code>, <code>per_route:login</code>. Apply a stricter limit on expensive or abusive routes (login, password reset, search) (<a href=\"/library/rust-rate-limiting-abuse\">rate limiting for abuse</a>). Avoid unbounded key growth from attacker-chosen values; keys expire, which bounds it.</p>\n<h2>The Axum layer and headers</h2>\n<pre><code class=\"language-rust\">use axum::{extract::{Request, State}, http::{header, HeaderValue, StatusCode}, middleware::Next, response::{IntoResponse, Response}};\n\npub async fn limit(State(app): State&lt;AppState&gt;, req: Request, next: Next) -&gt; Response {\n    let key = client_key(&amp;req);\n    let mut conn = app.redis.clone();\n\n    match token_bucket(&amp;mut conn, &amp;key, 100, 100.0 / 60.0).await {\n        Ok(d) if !d.allowed =&gt; {\n            let mut res = StatusCode::TOO_MANY_REQUESTS.into_response();\n            res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(d.retry_after_secs.max(1)));\n            res\n        }\n        Ok(d) =&gt; {\n            let mut res = next.run(req).await;\n            res.headers_mut().insert(\"x-ratelimit-remaining\", HeaderValue::from(d.remaining));\n            res\n        }\n        Err(e) =&gt; {\n            tracing::error!(\"rate limiter unavailable: {e}\");\n            if app.fail_open { next.run(req).await } else { StatusCode::SERVICE_UNAVAILABLE.into_response() }\n        }\n    }\n}</code></pre>\n<p><code>Retry-After</code> and <code>RateLimit-*</code> headers let clients pace themselves. Test the whole thing with paused time against a fake clock and, for the Redis part, a real Redis in a container (<a href=\"/library/rust-test-rate-limiters\">testing rate limiters</a>).</p>\n<h2>Failure policy</h2>\n<p>Redis down, slow or partitioned. Options:</p>\n<ul><li><strong>Fail open</strong> with a local in-process limiter at a looser (per-instance) limit as a backstop. Best for general APIs.</li><li><strong>Fail closed</strong> for abuse-sensitive endpoints, accepting that an outage blocks them.</li><li><strong>Timeouts</strong>: give the Redis call a tight timeout (tens of milliseconds) so the limiter never adds seconds of latency.</li></ul>\n<p>Log and count failures, and alert on them.</p>\n<h2>Alternatives</h2>\n<p>A gateway or CDN can enforce limits before traffic reaches you (Envoy, Nginx, cloud API gateways), which removes the code but ties the policy to infrastructure. Cell-based or sticky routing makes local limiters approximately right: route each client to one instance by key. For very high rates, approximate algorithms with local counters that sync every few hundred milliseconds trade accuracy for scale. See also <a href=\"/library/axum-rate-limiting\">Axum rate limiting</a> and <a href=\"/library/rust-redis-data-structures\">Redis data structures</a>.</p>","date_published":"2026-11-03T13:26:00+00:00","tags":["Architecture"]},{"id":"https://rusthacks.dev/library/rust-actor-model-tokio","url":"https://rusthacks.dev/library/rust-actor-model-tokio","title":"The Actor Model in Rust With Tokio Tasks and Channels","summary":"An actor is a task that owns its data and processes messages one at a time. Channels replace locks, and a handle type hides the plumbing.","content_html":"<p>Sharing mutable state between tasks usually means <code>Arc&lt;Mutex&lt;T&gt;&gt;</code>, and that works until a critical section needs an <code>.await</code>, or until the locking discipline spreads across the codebase. The actor model offers another structure: give the state to one task, and let everything else talk to it through messages. Tokio's channels make this natural. Code runs on Rust 1.90, Tokio 1.47.</p>\n<h2>A bank account actor</h2>\n<pre><code class=\"language-rust\">use tokio::sync::{mpsc, oneshot};\n\nenum Command {\n    Deposit { cents: u64 },\n    Withdraw { cents: u64, reply: oneshot::Sender&lt;Result&lt;u64, String&gt;&gt; },\n    Balance { reply: oneshot::Sender&lt;u64&gt; },\n}\n\nstruct AccountActor {\n    rx: mpsc::Receiver&lt;Command&gt;,\n    balance: u64,\n}\n\nimpl AccountActor {\n    async fn run(mut self) {\n        while let Some(cmd) = self.rx.recv().await {\n            match cmd {\n                Command::Deposit { cents } =&gt; self.balance += cents,\n                Command::Withdraw { cents, reply } =&gt; {\n                    let result = if cents &lt;= self.balance {\n                        self.balance -= cents;\n                        Ok(self.balance)\n                    } else {\n                        Err(format!(\"insufficient funds: have {}, need {}\", self.balance, cents))\n                    };\n                    let _ = reply.send(result);\n                }\n                Command::Balance { reply } =&gt; {\n                    let _ = reply.send(self.balance);\n                }\n            }\n        }\n        // all handles dropped: the loop ends and the task finishes\n    }\n}</code></pre>\n<p>The state (<code>balance</code>) is a plain field. Messages are processed one at a time, so check-then-update in <code>Withdraw</code> is atomic with no lock. The <code>reply</code> senders send a result back to the caller. Ignoring a failed <code>reply.send</code> is correct: the caller may have given up.</p>\n<h2>The handle</h2>\n<pre><code class=\"language-rust\">#[derive(Clone)]\npub struct AccountHandle {\n    tx: mpsc::Sender&lt;Command&gt;,\n}\n\n#[derive(Debug)]\npub struct ActorGone;\n\nimpl AccountHandle {\n    pub fn spawn(initial: u64) -&gt; (Self, tokio::task::JoinHandle&lt;()&gt;) {\n        let (tx, rx) = mpsc::channel(64);\n        let actor = AccountActor { rx, balance: initial };\n        let join = tokio::spawn(actor.run());\n        (Self { tx }, join)\n    }\n\n    pub async fn deposit(&amp;self, cents: u64) -&gt; Result&lt;(), ActorGone&gt; {\n        self.tx.send(Command::Deposit { cents }).await.map_err(|_| ActorGone)\n    }\n\n    pub async fn withdraw(&amp;self, cents: u64) -&gt; Result&lt;Result&lt;u64, String&gt;, ActorGone&gt; {\n        let (reply, rx) = oneshot::channel();\n        self.tx.send(Command::Withdraw { cents, reply }).await.map_err(|_| ActorGone)?;\n        rx.await.map_err(|_| ActorGone)\n    }\n\n    pub async fn balance(&amp;self) -&gt; Result&lt;u64, ActorGone&gt; {\n        let (reply, rx) = oneshot::channel();\n        self.tx.send(Command::Balance { reply }).await.map_err(|_| ActorGone)?;\n        rx.await.map_err(|_| ActorGone)\n    }\n}</code></pre>\n<p>Callers see ordinary async methods and a cloneable handle they can store in app state and pass to many tasks. The channel is bounded at 64, so if the actor is busy, <code>send().await</code> waits, applying backpressure to callers instead of growing memory (<a href=\"/library/rust-backpressure-design\">backpressure design</a>, <a href=\"/library/tokio-backpressure-bounded-channels\">bounded channels</a>). <code>ActorGone</code> distinguishes a dead actor from a business error, which is why <code>withdraw</code> returns a nested <code>Result</code>.</p>\n<pre><code class=\"language-rust\">#[tokio::main]\nasync fn main() {\n    let (account, join) = AccountHandle::spawn(1_000);\n\n    let a = account.clone();\n    let t1 = tokio::spawn(async move { a.withdraw(700).await });\n    let b = account.clone();\n    let t2 = tokio::spawn(async move { b.withdraw(700).await });\n\n    let (r1, r2) = (t1.await.unwrap().unwrap(), t2.await.unwrap().unwrap());\n    println!(\"{r1:?} {r2:?}\");     // exactly one succeeds\n    println!(\"balance {}\", account.balance().await.unwrap());\n\n    drop(account);                 // last handle gone, actor loop ends\n    join.await.unwrap();\n}</code></pre>\n<p>Two concurrent withdrawals of 700 from 1,000 cannot both succeed, since the actor serializes them. The shutdown line shows the lifecycle: when every <code>AccountHandle</code> is dropped, <code>recv()</code> returns <code>None</code> and the task completes.</p>\n<h2>Why this beats a lock here</h2>\n<ol><li><strong>Awaiting inside the critical section.</strong> An actor handler can <code>.await</code> a database call while it holds the state, and other callers just queue. A <code>std::sync::Mutex</code> guard across an await is a bug, and a Tokio mutex held across slow I/O stalls everyone anyway (<a href=\"/library/mutex-guard-across-await\">mutex guard across await</a>).</li><li><strong>A single owner for a resource.</strong> A connection, a file or a hardware device with one writer is naturally an actor.</li><li><strong>Serialized workflows.</strong> Multi-step state transitions are written as straight code without lock ordering.</li><li><strong>Observability.</strong> One queue to measure: its length is the load.</li></ol>\n<h2>Costs and hazards</h2>\n<ul><li><strong>Throughput is one task.</strong> If the work is CPU bound and per-key, shard: run one actor per key (a map from key to handle), or per hash bucket.</li><li><strong>Latency.</strong> Every call is a message round trip plus a task wake-up, microseconds rather than nanoseconds. Not for hot inner loops.</li><li><strong>Deadlock by self-call.</strong> If a handler awaits a call to its own handle, it waits for itself. Pass the data in the message instead, or use a separate task for the call.</li><li><strong>Cyclic waits</strong> between two actors that await each other's replies deadlock the same way. Prefer one-way messages and events (<a href=\"/library/rust-in-process-event-bus\">event bus</a>).</li><li><strong>Unbounded queues</strong> hide overload; use bounded channels and decide what a full queue means (wait, drop, error).</li><li><strong>Panics.</strong> A panic in a handler kills the actor, and every handle then returns <code>ActorGone</code>.</li></ul>\n<h2>Supervision</h2>\n<p>Watch the <code>JoinHandle</code> and restart or fail loudly:</p>\n<pre><code class=\"language-rust\">async fn supervise(initial: u64) {\n    loop {\n        let (handle, join) = AccountHandle::spawn(initial);\n        publish_handle(handle);               // e.g. replace the one in shared state\n        match join.await {\n            Ok(()) =&gt; break,                  // clean shutdown\n            Err(e) if e.is_panic() =&gt; tracing::error!(\"account actor panicked, restarting\"),\n            Err(_) =&gt; break,                  // cancelled\n        }\n    }\n}</code></pre>\n<p>Restarting loses in-memory state, so an actor that matters should persist state or rebuild it from storage on start. Frameworks such as ractor and kameo provide supervision strategies if you need them at volume.</p>\n<h2>When to use which tool</h2>\n<p>| Need | Tool | |---|---| | Simple shared counter or cache | <code>Arc&lt;Mutex&lt;T&gt;&gt;</code> or atomics | | Read-mostly configuration | <code>ArcSwap</code> or <code>watch</code> channel | | Single owner with async work, serialized commands | actor | | Broadcast notifications | <a href=\"/library/rust-in-process-event-bus\">event bus</a> | | Distributed, durable | a message broker |</p>\n<p>Related: <a href=\"/library/tokio-share-state\">sharing state in Tokio</a>, <a href=\"/library/tokio-channels-compared\">channels compared</a> and <a href=\"/library/rust-chat-server-tokio\">writing a chat server with broadcast</a>.</p>","date_published":"2026-11-03T13:20:00+00:00","tags":["Architecture"]},{"id":"https://rusthacks.dev/library/rust-in-process-event-bus","url":"https://rusthacks.dev/library/rust-in-process-event-bus","title":"An In Process Event Bus in Rust With Broadcast Channels","summary":"An event bus lets parts of a program react to things without knowing about each other. Tokio's broadcast channel gives a small, typed one with built-in handling for slow subscribers.","content_html":"<p>As an application grows, one action starts to trigger many reactions: an order is placed, so the confirmation email is queued, the cache is cleared, the audit log is written and the metrics counter increments. Calling all of them from the order code couples it to every consumer. An event bus inverts that: the order code publishes <code>OrderPlaced</code>, and interested modules subscribe. Within one process this takes about forty lines. Versions: Rust 1.90, Tokio 1.47.</p>\n<h2>The bus</h2>\n<pre><code class=\"language-rust\">use tokio::sync::broadcast;\n\n#[derive(Debug, Clone)]\npub enum Event {\n    OrderPlaced { order_id: u64, user_id: u64, total_cents: i64 },\n    OrderCancelled { order_id: u64 },\n    UserRegistered { user_id: u64 },\n}\n\n#[derive(Clone)]\npub struct EventBus {\n    tx: broadcast::Sender&lt;Event&gt;,\n}\n\nimpl EventBus {\n    pub fn new(capacity: usize) -&gt; Self {\n        let (tx, _) = broadcast::channel(capacity);\n        Self { tx }\n    }\n\n    pub fn publish(&amp;self, event: Event) {\n        // Err only means nobody is subscribed\n        let _ = self.tx.send(event);\n    }\n\n    pub fn subscribe(&amp;self) -&gt; broadcast::Receiver&lt;Event&gt; {\n        self.tx.subscribe()\n    }\n}</code></pre>\n<p><code>EventBus</code> is cheap to clone (the sender is reference counted), so each module holds its own handle, typically passed in at construction (<a href=\"/library/rust-dependency-injection\">dependency injection</a>). <code>Event</code> derives <code>Clone</code> because each subscriber gets its own copy.</p>\n<h2>Subscribers</h2>\n<p>A subscriber is a task with a receive loop:</p>\n<pre><code class=\"language-rust\">use tokio::sync::broadcast::error::RecvError;\n\npub fn spawn_audit_logger(bus: &amp;EventBus) -&gt; tokio::task::JoinHandle&lt;()&gt; {\n    let mut rx = bus.subscribe();\n    tokio::spawn(async move {\n        loop {\n            match rx.recv().await {\n                Ok(Event::OrderPlaced { order_id, total_cents, .. }) =&gt; {\n                    tracing::info!(order_id, total_cents, \"order placed\");\n                }\n                Ok(_) =&gt; {}\n                Err(RecvError::Lagged(missed)) =&gt; {\n                    tracing::warn!(missed, \"audit logger fell behind and missed events\");\n                }\n                Err(RecvError::Closed) =&gt; break,\n            }\n        }\n    })\n}\n\npub fn spawn_cache_invalidator(bus: &amp;EventBus, cache: moka::future::Cache&lt;u64, String&gt;) {\n    let mut rx = bus.subscribe();\n    tokio::spawn(async move {\n        while let Ok(event) = rx.recv().await {\n            if let Event::OrderCancelled { order_id } = event {\n                cache.invalidate(&amp;order_id).await;\n            }\n        }\n    });\n}</code></pre>\n<p>Each subscriber filters for the variants it cares about and ignores the rest. Adding a new reaction means adding a subscriber, and the producer does not change. That is the decoupling.</p>\n<h2>Publishing</h2>\n<pre><code class=\"language-rust\">pub struct OrderService {\n    bus: EventBus,\n}\n\nimpl OrderService {\n    pub async fn place(&amp;self, user_id: u64, total_cents: i64) -&gt; u64 {\n        let order_id = 1001; // persisted in a real service\n        self.bus.publish(Event::OrderPlaced { order_id, user_id, total_cents });\n        order_id\n    }\n}</code></pre>\n<p><code>publish</code> is synchronous and never waits for subscribers, so a slow consumer cannot slow the order path. That is both the strength and the hazard.</p>\n<h2>Lag: the central trade-off</h2>\n<p>The channel holds <code>capacity</code> events. If a subscriber falls behind by more than that, the oldest events are discarded for it and its next <code>recv</code> returns <code>Lagged(n)</code>. The bus does not block the producer to protect slow consumers. Consequences:</p>\n<ul><li>Choose capacity for bursts: hundreds or thousands for busy buses.</li><li>Subscribers must be fast: do work in the loop only if it is quick, or hand off to a bounded queue of their own and handle overload explicitly (<a href=\"/library/rust-backpressure-design\">backpressure design</a>).</li><li>Lag is a signal. Log it, count it, alert on it.</li><li>A subscriber that must never miss an event cannot rely on this bus.</li></ul>\n<p>For that case use an <code>mpsc</code> channel per critical subscriber (one sender per consumer, held by the bus), which applies backpressure, or a durable queue (<a href=\"/library/rust-background-jobs\">background jobs</a>, <a href=\"/library/tokio-channels-compared\">Tokio channels compared</a>).</p>\n<h2>Ordering and delivery</h2>\n<p>Within one subscriber, events arrive in publication order. Across subscribers there is no ordering. Delivery is at most once per subscriber: no acknowledgements, no retries. Subscribers added later see only later events; a subscriber that starts after <code>UserRegistered</code> was published never sees it. If a handler panics, only its task dies, so supervise: restart the task or log loudly.</p>\n<h2>Typed topics instead of one enum</h2>\n<p>One enum is easy and keeps a single channel. If different events have different audiences and rates, run separate buses (<code>OrderBus</code>, <code>UserBus</code>) so a flood of one type does not push another's events out of a shared buffer. Alternatively use a <code>HashMap&lt;TypeId, broadcast::Sender&lt;...&gt;&gt;</code> for a type-keyed bus; the added complexity is rarely worth it.</p>\n<h2>Testing</h2>\n<pre><code class=\"language-rust\">#[tokio::test]\nasync fn subscribers_receive_published_events() {\n    let bus = EventBus::new(16);\n    let mut a = bus.subscribe();\n    let mut b = bus.subscribe();\n\n    bus.publish(Event::UserRegistered { user_id: 7 });\n\n    assert!(matches!(a.recv().await.unwrap(), Event::UserRegistered { user_id: 7 }));\n    assert!(matches!(b.recv().await.unwrap(), Event::UserRegistered { user_id: 7 }));\n}\n\n#[tokio::test]\nasync fn slow_subscriber_sees_lag_not_a_hang() {\n    let bus = EventBus::new(2);\n    let mut rx = bus.subscribe();\n    for i in 0..5 {\n        bus.publish(Event::OrderCancelled { order_id: i });\n    }\n    assert!(matches!(rx.recv().await, Err(broadcast::error::RecvError::Lagged(3))));\n    assert!(matches!(rx.recv().await.unwrap(), Event::OrderCancelled { order_id: 3 }));\n}</code></pre>\n<p>The second test pins the lag behavior, which is the contract your subscribers must handle.</p>\n<h2>When not to use an event bus</h2>\n<p>If one specific thing must happen after another, call it directly: events hide control flow and make \"what runs when X happens?\" harder to answer. Use the bus for optional side effects and notifications, not for the core of a transaction. When the work must be reliable, record the event in the database in the same transaction and process it asynchronously (<a href=\"/library/rust-outbox-pattern\">outbox pattern</a>). Actors are an alternative structure for owned state (<a href=\"/library/rust-actor-model-tokio\">actor model</a>). For cross-process events see <a href=\"/library/rust-kafka-nats-compared\">Kafka and NATS compared</a>, and for the fan-out design in pipelines <a href=\"/library/rust-fan-out-fan-in-pipelines\">fan-out and fan-in</a>.</p>","date_published":"2026-11-03T13:18:00+00:00","tags":["Architecture"]},{"id":"https://rusthacks.dev/library/rust-test-rate-limiters","url":"https://rusthacks.dev/library/rust-test-rate-limiters","title":"Testing Rate Limiters and Quotas in Rust Web Services","summary":"A limiter is a function of time, so tests should control time. Assert the boundaries: the last allowed request, the first rejected one and the moment capacity returns.","content_html":"<p>Rate limiting code is simple to write and easy to get slightly wrong: off by one at the limit, a window that never resets or one key affecting another. Tests that rely on real time are slow and flaky, so the first rule is to take control of the clock. The examples use Rust 1.90, Tokio 1.47 and Axum 0.8, with the token bucket from <a href=\"/library/rust-rate-limiter-token-bucket\">building a rate limiter</a>.</p>\n<h2>Algorithm tests with paused time</h2>\n<pre><code class=\"language-rust\">use std::time::Duration;\n\n#[tokio::test(start_paused = true)]\nasync fn allows_burst_then_rejects() {\n    let mut bucket = TokenBucket::new(3, 1.0); // capacity 3, refill 1 per second\n\n    assert!(bucket.try_acquire());\n    assert!(bucket.try_acquire());\n    assert!(bucket.try_acquire());\n    assert!(!bucket.try_acquire(), \"fourth request exceeds the burst\");\n}\n\n#[tokio::test(start_paused = true)]\nasync fn refills_at_the_configured_rate() {\n    let mut bucket = TokenBucket::new(2, 1.0);\n    assert!(bucket.try_acquire());\n    assert!(bucket.try_acquire());\n    assert!(!bucket.try_acquire());\n\n    tokio::time::advance(Duration::from_millis(999)).await;\n    assert!(!bucket.try_acquire(), \"not yet one full second\");\n\n    tokio::time::advance(Duration::from_millis(2)).await;\n    assert!(bucket.try_acquire(), \"one token has accumulated\");\n    assert!(!bucket.try_acquire());\n}\n\n#[tokio::test(start_paused = true)]\nasync fn never_exceeds_capacity_after_a_long_pause() {\n    let mut bucket = TokenBucket::new(2, 1.0);\n    tokio::time::advance(Duration::from_secs(3600)).await;\n    assert!(bucket.try_acquire());\n    assert!(bucket.try_acquire());\n    assert!(!bucket.try_acquire(), \"tokens are capped at capacity\");\n}</code></pre>\n<p><code>start_paused = true</code> freezes Tokio's clock, and <code>advance</code> moves it exactly. The 999 millisecond and 1001 millisecond steps test the boundary around one second. This works because the limiter reads <code>tokio::time::Instant</code>, not <code>std::time::Instant</code> (<a href=\"/library/rust-test-time-injected-clock\">injected clocks</a>). If your limiter uses its own <code>Clock</code> trait, give tests a fake with a settable <code>now</code>.</p>\n<h2>Fixed and sliding windows</h2>\n<p>A fixed window counter needs boundary tests that a token bucket does not: requests at the end of one window and the start of the next.</p>\n<pre><code class=\"language-rust\">#[tokio::test(start_paused = true)]\nasync fn fixed_window_resets_at_the_boundary() {\n    let limiter = FixedWindow::new(2, Duration::from_secs(60));\n    assert!(limiter.check(\"k\").await);\n    assert!(limiter.check(\"k\").await);\n    assert!(!limiter.check(\"k\").await);\n\n    tokio::time::advance(Duration::from_secs(60)).await;\n    assert!(limiter.check(\"k\").await, \"new window\");\n}</code></pre>\n<p>State the known weakness in a test too: a burst straddling two windows can allow double the limit, which is why sliding windows exist (<a href=\"/library/rust-distributed-rate-limiting\">distributed rate limiting</a>).</p>\n<h2>Per-key isolation</h2>\n<pre><code class=\"language-rust\">#[tokio::test(start_paused = true)]\nasync fn one_client_cannot_exhaust_anothers_budget() {\n    let limiter = KeyedLimiter::new(1, 1.0);\n    assert!(limiter.check(\"alice\").await);\n    assert!(!limiter.check(\"alice\").await);\n    assert!(limiter.check(\"bob\").await, \"bob has his own bucket\");\n}</code></pre>\n<p>Also test that the map of keys does not grow without bound: after a long idle period, entries should be evicted (<code>limiter.len()</code> returns to zero).</p>\n<h2>The HTTP contract</h2>\n<pre><code class=\"language-rust\">use axum::{body::Body, http::{Request, StatusCode}, routing::get, Router};\nuse tower::ServiceExt;\n\nfn app() -&gt; Router {\n    Router::new()\n        .route(\"/api\", get(|| async { \"ok\" }))\n        .layer(rate_limit_layer(2, Duration::from_secs(60)))\n}\n\n#[tokio::test(start_paused = true)]\nasync fn returns_429_with_retry_after() {\n    let app = app();\n    for _ in 0..2 {\n        let res = app.clone().oneshot(Request::get(\"/api\").body(Body::empty()).unwrap()).await.unwrap();\n        assert_eq!(res.status(), StatusCode::OK);\n    }\n\n    let res = app.clone().oneshot(Request::get(\"/api\").body(Body::empty()).unwrap()).await.unwrap();\n    assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);\n    let retry: u64 = res.headers()[\"retry-after\"].to_str().unwrap().parse().unwrap();\n    assert!((1..=60).contains(&amp;retry));\n\n    tokio::time::advance(Duration::from_secs(61)).await;\n    let res = app.oneshot(Request::get(\"/api\").body(Body::empty()).unwrap()).await.unwrap();\n    assert_eq!(res.status(), StatusCode::OK);\n}</code></pre>\n<p>Clients rely on <code>Retry-After</code>, so assert its range, and consider <code>RateLimit-Remaining</code> headers if you expose them. Identify the client key from the right source: tests should send distinct <code>X-Forwarded-For</code> values from a trusted proxy address and check they are separate buckets, plus that the header from an untrusted peer is ignored (<a href=\"/library/axum-behind-reverse-proxy\">behind a reverse proxy</a>, <a href=\"/library/axum-rate-limiting\">Axum rate limiting</a>).</p>\n<h2>Concurrency</h2>\n<p>A limiter that checks and decrements non-atomically allows extra requests under load:</p>\n<pre><code class=\"language-rust\">#[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\nasync fn concurrent_requests_never_exceed_budget() {\n    let limiter = std::sync::Arc::new(KeyedLimiter::new(50, 0.0)); // no refill\n    let mut tasks = Vec::new();\n    for _ in 0..500 {\n        let l = limiter.clone();\n        tasks.push(tokio::spawn(async move { l.check(\"k\").await }));\n    }\n    let mut allowed = 0;\n    for t in tasks {\n        if t.await.unwrap() {\n            allowed += 1;\n        }\n    }\n    assert_eq!(allowed, 50);\n}</code></pre>\n<p>With a zero refill rate the allowed count must be exactly the capacity. Run it repeatedly; the race, if there is one, shows quickly. For lock-free implementations, model check the core with loom (<a href=\"/library/rust-test-concurrency-loom\">loom</a>).</p>\n<h2>Shared stores</h2>\n<p>A Redis-backed limiter needs integration tests against a real Redis (Testcontainers), since Lua script behavior and expiry are the point. Verify key expiry by advancing the server clock or using short windows. Keep the algorithm tests above, which are fast, as the main safety net.</p>","date_published":"2026-11-03T12:12:00+00:00","tags":["Testing"]},{"id":"https://rusthacks.dev/library/rust-test-concurrency-loom","url":"https://rusthacks.dev/library/rust-test-concurrency-loom","title":"Testing Concurrent Rust Code With Loom Interleavings","summary":"Stress tests find concurrency bugs by luck. Loom explores every legal ordering of your threads and memory operations, so a missing Acquire fails deterministically.","content_html":"<p>Concurrency bugs depend on timing. A test that passes a million times can still hide a race that appears on a different CPU. Loom is a model checker for Rust: it runs your code under every ordering that the memory model allows within a bound. It is how Tokio tests its synchronization primitives. Versions: Rust 1.90, <code>loom</code> 0.7.</p>\n<h2>What goes wrong without it</h2>\n<pre><code class=\"language-rust\">use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};\nuse std::sync::Arc;\nuse std::thread;\n\n#[test]\nfn publish_data() {\n    let data = Arc::new(AtomicUsize::new(0));\n    let ready = Arc::new(AtomicBool::new(false));\n\n    let (d, r) = (data.clone(), ready.clone());\n    let writer = thread::spawn(move || {\n        d.store(42, Ordering::Relaxed);\n        r.store(true, Ordering::Relaxed); // bug: should be Release\n    });\n\n    while !ready.load(Ordering::Relaxed) {} // bug: should be Acquire\n    assert_eq!(data.load(Ordering::Relaxed), 42);\n    writer.join().unwrap();\n}</code></pre>\n<p>On x86 this nearly always passes, because the hardware orders stores strongly. On ARM or under compiler reordering the reader may see <code>ready</code> before <code>data</code>. A normal test cannot show it.</p>\n<h2>The loom version</h2>\n<p>Add a <code>cfg</code> switch so the crate uses loom's types in tests:</p>\n<pre><code class=\"language-toml\">[target.'cfg(loom)'.dependencies]\nloom = \"0.7\"\n\n[lints.rust]\nunexpected_cfgs = { level = \"warn\", check-cfg = [\"cfg(loom)\"] }</code></pre>\n<pre><code class=\"language-rust\">#[cfg(loom)]\nuse loom::{sync::{atomic::{AtomicBool, AtomicUsize, Ordering}, Arc}, thread};\n#[cfg(not(loom))]\nuse std::{sync::{atomic::{AtomicBool, AtomicUsize, Ordering}, Arc}, thread};</code></pre>\n<p>Library code then uses these imports instead of <code>std::sync</code> directly. The test:</p>\n<pre><code class=\"language-rust\">#[cfg(loom)]\n#[test]\nfn publish_data_loom() {\n    loom::model(|| {\n        let data = Arc::new(AtomicUsize::new(0));\n        let ready = Arc::new(AtomicBool::new(false));\n\n        let (d, r) = (data.clone(), ready.clone());\n        let writer = thread::spawn(move || {\n            d.store(42, Ordering::Relaxed);\n            r.store(true, Ordering::Relaxed);\n        });\n\n        while !ready.load(Ordering::Relaxed) {\n            loom::thread::yield_now();\n        }\n        assert_eq!(data.load(Ordering::Relaxed), 42);\n        writer.join().unwrap();\n    });\n}</code></pre>\n<pre><code class=\"language-bash\">RUSTFLAGS=\"--cfg loom\" cargo test --release publish_data_loom</code></pre>\n<pre><code class=\"language-text\">thread 'publish_data_loom' panicked at src/lib.rs:31:9:\nassertion `left == right` failed\n  left: 0\n right: 42</code></pre>\n<p>Loom found an execution where <code>ready</code> is true and <code>data</code> is still 0. Change the stores and loads to <code>Release</code> and <code>Acquire</code>:</p>\n<pre><code class=\"language-rust\">r.store(true, Ordering::Release);\nwhile !ready.load(Ordering::Acquire) { loom::thread::yield_now(); }</code></pre>\n<p>and the model passes, having explored all legal orderings. Spin loops need <code>yield_now()</code>, otherwise loom cannot progress the other thread. Run in <code>--release</code>, since the exploration is CPU heavy.</p>\n<h2>Types you can model</h2>\n<p>| std | loom | |---|---| | <code>std::sync::atomic::*</code> | <code>loom::sync::atomic::*</code> | | <code>std::sync::{Mutex, RwLock, Condvar}</code> | <code>loom::sync::*</code> | | <code>std::sync::Arc</code> | <code>loom::sync::Arc</code> | | <code>std::thread::spawn</code> | <code>loom::thread::spawn</code> | | <code>std::cell::UnsafeCell</code> | <code>loom::cell::UnsafeCell</code> (detects data races) |</p>\n<p>The <code>loom::cell::UnsafeCell</code> variant has <code>with</code> and <code>with_mut</code> methods that panic if two threads would access the data concurrently without synchronization. That check is the strongest reason to use loom with lock-free code (<a href=\"/library/rust-atomics-explained\">atomics explained</a>).</p>\n<h2>Keeping models small</h2>\n<p>The number of schedules explodes with each thread and each operation. Practical rules:</p>\n<ul><li>Two or three threads, a few operations each.</li><li>Test one primitive at a time: a queue, a latch, a reference count.</li><li>Bound preemptions: <code>LOOM_MAX_PREEMPTIONS=3 cargo test</code> (the default checks exhaustively within the bound the library sets).</li><li>Use <code>LOOM_LOG=trace</code> to see the failing schedule, and <code>LOOM_CHECKPOINT_FILE</code> to replay it.</li></ul>\n<h2>What loom does not cover</h2>\n<ol><li><strong>Async runtimes.</strong> Tokio provides its own loom integration. For application async code use deterministic scheduling tools instead (<a href=\"/library/rust-test-graceful-shutdown\">testing graceful shutdown</a>, <a href=\"/library/rust-test-retries-timeouts\">paused time</a>).</li><li><strong>Larger systems.</strong> Models of whole programs do not finish.</li><li><strong>Platform details.</strong> It models the C11 memory model, which is the contract Rust gives you.</li><li><strong>Performance.</strong> It says nothing about speed.</li></ol>\n<h2>When it is worth it</h2>\n<p>Reach for loom when you write code with atomics, <code>UnsafeCell</code>, custom locks or lock-free structures, where a bug means a rare crash in production. For ordinary mutex-protected state, stress tests and careful design are enough ([<code>Arc&lt;Mutex&lt;T&gt;&gt;</code>](/library/rust-arc-mutex-pattern)). The companion for undefined behavior in unsafe code is Miri (<a href=\"/library/rust-miri-undefined-behavior\">Miri</a>).</p>","date_published":"2026-11-03T11:58:00+00:00","tags":["Testing"]},{"id":"https://rusthacks.dev/library/rust-postgres-vs-sqlite-choice","url":"https://rusthacks.dev/library/rust-postgres-vs-sqlite-choice","title":"Choosing Between Postgres and SQLite for a New Rust Service","summary":"Both are excellent, and the difference is architecture. SQLite is a library inside your process, and Postgres is a server your process talks to. Pick by how many writers and machines you need.","content_html":"<p>The default answer for a new backend is Postgres, and that is usually right. It is not always right. A growing number of services run on SQLite in production because they fit its shape: one node, modest writes, large reads. The choice depends on a handful of questions that you can answer before writing code. Versions in mind: Postgres 17, SQLite 3.46, SQLx 0.8, Rust 1.90.</p>\n<h2>The core difference</h2>\n<p>| | SQLite | Postgres | |---|---|---| | Architecture | library in your process, one file | server process, network protocol | | Writers | one at a time | many concurrent (MVCC) | | Readers | many, in-process, no network | many, over connections | | Access from several machines | not supported on shared file systems | built for it | | Setup | none | install, configure, secure, monitor | | Types | dynamic typing, few types | rich types: arrays, JSONB, ranges, enums | | Extensions | limited (FTS5, JSON, R-Tree) | pgvector, PostGIS, pg_cron and more | | Backups | copy the file, Litestream | pg_dump, base backups, WAL archiving, managed | | HA and failover | external tooling | replicas, managed failover |</p>\n<p>Everything else follows from these rows.</p>\n<h2>Questions that decide it</h2>\n<p><strong>1. How many application instances will write?</strong> One instance: SQLite works. Two or more behind a load balancer: Postgres. Zero-downtime deploys with overlapping instances also point to Postgres, since two processes writing the same file during a rollout is a hazard.</p>\n<p><strong>2. How write-heavy is it?</strong> SQLite's single writer sustains thousands of small transactions per second on an SSD, which covers most applications. Hundreds of concurrent long-running write transactions do not fit.</p>\n<p><strong>3. Do other systems need to query the data?</strong> BI tools, workers on other machines and analysts connect to Postgres natively. With SQLite you export or replicate.</p>\n<p><strong>4. Which features do you need?</strong> Full-text search exists in both, with different strengths. Vector search, geospatial queries, row level security and listen/notify are Postgres strengths (<a href=\"/library/rust-vector-search-pgvector\">pgvector</a>, <a href=\"/library/rust-postgres-row-level-security\">RLS</a>, <a href=\"/library/rust-postgres-listen-notify\">LISTEN/NOTIFY</a>).</p>\n<p><strong>5. Who operates it?</strong> A solo developer or small team may prefer no database server. A team with a platform group or a managed Postgres has the opposite situation.</p>\n<p><strong>6. What is the growth path?</strong> If you expect to need multiple nodes within a year, starting on Postgres avoids a migration. If the service will stay small, SQLite avoids a permanent operational cost.</p>\n<h2>How the Rust code differs</h2>\n<p>The connection model changes the design. SQLite wants one writer connection plus reader connections (<a href=\"/library/rust-sqlite-production-litestream\">SQLite in production</a>). Postgres wants a pool sized to the server (<a href=\"/library/sqlx-connection-pool-sizing\">pool sizing</a>). With SQLx, you pick a different feature and type for the pool:</p>\n<pre><code class=\"language-toml\"># SQLite\nsqlx = { version = \"0.8\", features = [\"runtime-tokio\", \"sqlite\", \"migrate\"] }\n# Postgres\nsqlx = { version = \"0.8\", features = [\"runtime-tokio\", \"postgres\", \"migrate\"] }</code></pre>\n<p>SQL dialect differences appear quickly:</p>\n<p>| Concern | SQLite | Postgres | |---|---|---| | Placeholders | <code>?</code> or <code>?1</code> | <code>$1</code> | | Auto IDs | <code>INTEGER PRIMARY KEY</code> | <code>bigserial</code> or identity | | Timestamps | text or integer | <code>timestamptz</code> | | Upsert | <code>ON CONFLICT</code> (supported) | <code>ON CONFLICT</code> | | Booleans | integers 0 and 1 | <code>boolean</code> | | <code>ALTER TABLE</code> | limited | extensive | | Type enforcement | flexible unless <code>STRICT</code> | strict |</p>\n<p>Use <code>STRICT</code> tables in SQLite to get type checking closer to Postgres.</p>\n<h2>Keeping the door open</h2>\n<ol><li>Put data access behind a repository trait so only one module knows SQL (<a href=\"/library/rust-repository-pattern\">repository pattern</a>).</li><li>Avoid dialect-specific tricks in shared queries. Test the same migrations on both if you plan to switch.</li><li>Use UUIDs or application-generated IDs, which move between systems easily (<a href=\"/library/rust-uuid-vs-integer-keys\">UUIDs versus integers</a>).</li><li>Store timestamps in UTC in a consistent format (<a href=\"/library/rust-timestamps-timezones-postgres\">timestamps and zones</a>).</li><li>Keep schema changes in versioned migrations.</li></ol>\n<h2>Moving from SQLite to Postgres</h2>\n<p>Export tables as CSV, load with <code>COPY</code> (<a href=\"/library/rust-postgres-copy-bulk-load\">bulk loading with COPY</a>), convert text timestamps and 0/1 booleans, recreate constraints and indexes, and run the application's tests against Postgres. <code>pgloader</code> automates much of it. Dual-write is rarely necessary at this scale, and a short maintenance window is simpler.</p>\n<h2>A checklist</h2>\n<p>Pick <strong>SQLite</strong> if: single node, writes under a few hundred per second, simple deployment valued, data under tens of gigabytes, team small.</p>\n<p>Pick <strong>Postgres</strong> if: more than one app instance, other systems need the data, advanced features, regulatory needs for replication, or a managed service is easier for your organization.</p>\n<p>When unsure and the cost of a wrong guess is low, choose the one you can operate confidently. Both are mature. For related design choices, see <a href=\"/library/sqlx-vs-diesel-vs-seaorm\">SQLx versus Diesel versus SeaORM</a>.</p>","date_published":"2026-11-03T11:48:00+00:00","tags":["Databases"]},{"id":"https://rusthacks.dev/library/rust-postgres-isolation-levels","url":"https://rusthacks.dev/library/rust-postgres-isolation-levels","title":"Postgres Isolation Levels and Serialization Retries in Rust","summary":"Higher isolation levels prevent anomalies by aborting transactions that conflict. Your code must expect those aborts and retry the whole transaction.","content_html":"<p>Two requests update the same account at the same moment. Whether the result is right depends on isolation, which most application code never thinks about until a balance goes negative. This post explains what the levels in Postgres guarantee and how to write Rust that works with the failures the stronger levels produce. Versions: Postgres 17, SQLx 0.8, Rust 1.90.</p>\n<h2>The levels</h2>\n<p>| Level | Each statement sees | Prevents | Still allows | |---|---|---|---| | <code>READ COMMITTED</code> (default) | rows committed before the statement began | dirty reads | non-repeatable reads, phantoms, write skew | | <code>REPEATABLE READ</code> | a snapshot from the transaction's first statement | the above except write skew | write skew | | <code>SERIALIZABLE</code> | snapshot plus conflict detection | all anomalies | nothing, but may abort |</p>\n<p>Postgres has no dirty reads at any level (<code>READ UNCOMMITTED</code> behaves as <code>READ COMMITTED</code>).</p>\n<h2>A write skew example</h2>\n<p>Two on-call doctors each check that at least two are on call, then each takes themselves off:</p>\n<pre><code class=\"language-sql\">-- both transactions\nSELECT count(*) FROM on_call WHERE shift = 'night';        -- sees 2\nUPDATE on_call SET active = false WHERE doctor = :me;      -- different rows\nCOMMIT;</code></pre>\n<p>Under <code>READ COMMITTED</code> and <code>REPEATABLE READ</code> both commit, leaving zero doctors on call, since neither wrote the other's row. Under <code>SERIALIZABLE</code>, Postgres detects the read-write dependency cycle and aborts one transaction:</p>\n<pre><code class=\"language-text\">ERROR:  could not serialize access due to read/write dependencies among transactions\nDETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.\nHINT:  The transaction might succeed if retried.</code></pre>\n<p>That is SQLSTATE <code>40001</code>. The error is the feature: the database refused to let the invariant break.</p>\n<h2>Choosing the level in SQLx</h2>\n<pre><code class=\"language-rust\">use sqlx::{PgPool, Postgres, Transaction};\n\nasync fn begin_serializable(pool: &amp;PgPool) -&gt; Result&lt;Transaction&lt;'_, Postgres&gt;, sqlx::Error&gt; {\n    let mut tx = pool.begin().await?;\n    sqlx::query(\"SET TRANSACTION ISOLATION LEVEL SERIALIZABLE\").execute(&amp;mut *tx).await?;\n    Ok(tx)\n}</code></pre>\n<p><code>SET TRANSACTION</code> must be the first statement in the transaction. Alternatively set <code>default_transaction_isolation</code> for a role or a connection.</p>\n<h2>The retry loop</h2>\n<pre><code class=\"language-rust\">use std::time::Duration;\n\nfn is_retryable(e: &amp;sqlx::Error) -&gt; bool {\n    match e {\n        sqlx::Error::Database(db) =&gt; matches!(db.code().as_deref(), Some(\"40001\") | Some(\"40P01\")),\n        _ =&gt; false,\n    }\n}\n\npub async fn with_retry&lt;T, F, Fut&gt;(pool: &amp;PgPool, mut work: F) -&gt; Result&lt;T, sqlx::Error&gt;\nwhere\n    F: FnMut(Transaction&lt;'static, Postgres&gt;) -&gt; Fut,\n    Fut: std::future::Future&lt;Output = Result&lt;T, sqlx::Error&gt;&gt;,\n{\n    let mut attempt = 0u32;\n    loop {\n        let tx = pool.begin().await?;\n        match work(tx).await {\n            Ok(v) =&gt; return Ok(v),\n            Err(e) if is_retryable(&amp;e) &amp;&amp; attempt &lt; 5 =&gt; {\n                attempt += 1;\n                let backoff = Duration::from_millis(10 * 2u64.pow(attempt) + fastrand::u64(0..10));\n                tracing::warn!(attempt, \"retrying transaction after conflict\");\n                tokio::time::sleep(backoff).await;\n            }\n            Err(e) =&gt; return Err(e),\n        }\n    }\n}</code></pre>\n<p>The closure takes ownership of a fresh transaction and is expected to commit it. Because the body can run several times, it must be free of side effects other than database writes. A conflict can appear at any statement or at <code>COMMIT</code>, so the check wraps the whole closure including the commit. Cap attempts and add jitter, otherwise colliding transactions retry in lockstep (<a href=\"/library/rust-async-resilience-patterns\">async resilience patterns</a>).</p>\n<h2>Deadlocks</h2>\n<p>Two transactions that lock rows in opposite order wait for each other. Postgres detects this after <code>deadlock_timeout</code> (1 s by default) and aborts one with <code>40P01</code>. The same retry handles it, and the better fix is a consistent lock order: always update rows sorted by id.</p>\n<h2>Explicit locking at the default level</h2>\n<p>Often you do not need <code>SERIALIZABLE</code>. A targeted lock expresses the intent:</p>\n<pre><code class=\"language-sql\">SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;</code></pre>\n<p>Other transactions touching that row wait. For queues, <code>FOR UPDATE SKIP LOCKED</code> (<a href=\"/library/rust-postgres-skip-locked-queue\">SKIP LOCKED queues</a>). For counters, a single <code>UPDATE accounts SET balance = balance - $2 WHERE id = $1 AND balance &gt;= $2</code> is atomic without any extra level. For versioned rows, optimistic checks (<a href=\"/library/rust-optimistic-locking-sqlx\">optimistic locking</a>).</p>\n<h2>Guidelines</h2>\n<ol><li>Keep transactions short: no network calls or user waits inside.</li><li>Make retried work idempotent, and keep emails and webhooks outside, using an outbox (<a href=\"/library/rust-outbox-pattern\">outbox pattern</a>).</li><li>Use <code>SERIALIZABLE</code> selectively, for the transactions that guard cross-row invariants, and measure abort rates.</li><li>Read-only analytical queries can use <code>REPEATABLE READ READ ONLY DEFERRABLE</code> for a consistent snapshot without aborts.</li><li>Test concurrency with two tasks and a barrier, to reproduce the anomaly deliberately (<a href=\"/library/rust-test-concurrency-loom\">testing concurrent code</a> for in-process races).</li></ol>\n<p>For the transaction API itself see <a href=\"/library/sqlx-transactions\">SQLx transactions</a>.</p>","date_published":"2026-11-03T11:36:00+00:00","tags":["Databases"]},{"id":"https://rusthacks.dev/library/rust-build-disk-usage-cli","url":"https://rusthacks.dev/library/rust-build-disk-usage-cli","title":"Build a Disk Usage Analyzer in Rust Like du and ncdu","summary":"du walks a tree and adds up file sizes. The interesting decisions are hard links counted once, symlinks left alone and how to keep going when a directory is unreadable.","content_html":"<p>A disk is full and you want to know why. <code>du</code> answers, in a way that is accurate and awkward to read, and <code>ncdu</code> answers interactively. Building a small version yourself, <code>rdu</code>, is a good exercise because the problem looks trivial, add up the file sizes, and is not. \"Size\" has two meanings that differ by 10 percent on a tidy directory and by a factor of ten on a directory full of small files. Hard links make the same bytes appear in two places. A symlink to a parent directory loops forever if followed. Parallel scanning makes the answer depend on thread timing unless you design against it. And a directory you cannot read must not stop the report.</p>\n<p>This guide builds <code>rdu</code> step by step, finds a real nondeterminism bug in the first parallel version by running it twelve times, fixes it, checks the result against <code>du</code>, and measures the speed. It was written on Rust 1.99 (edition 2024) with <code>rayon</code> 1.12 and <code>clap</code> 4.6.7, on macOS, so file-system details such as block size and APFS behavior reflect that platform. The output blocks are real.</p>\n<h2>Two sizes: what you wrote and what you pay for</h2>\n<p>A file has an apparent size, the number of bytes in its contents, and a disk usage, the number of bytes of storage the file system allocated for it. They differ for three reasons. Storage is allocated in blocks, so a 9-byte file costs a whole block. Sparse files have holes that cost nothing. And compressing file systems store less than the logical size. The standard library exposes both through <code>std::fs::Metadata</code>: <code>len()</code> is apparent size, and on Unix the <code>MetadataExt::blocks()</code> method returns the number of 512-byte units allocated.</p>\n<pre data-filename=\"src/scan.rs\"><code class=\"language-rust\">fn measure(md: &amp;fs::Metadata, how: Measure) -&gt; u64 {\n    match how {\n        Measure::Apparent =&gt; md.len(),\n        Measure::Disk =&gt; md.blocks() * 512,\n    }\n}</code></pre>\n<p><code>du</code> reports disk usage by default and <code>du --apparent-size</code> reports the other. The tool should do the same, and name the choice in a flag. The difference is visible on a small directory built to show it: a 5 MB file, a 3 MB file, an 800 KB file, 200 tiny log files of a few bytes each, a 120 KB source file, plus empty directories.</p>\n<pre><code class=\"language-text\">$ rdu /tmp/du_demo\n   9.3 MiB  100.0%  ####################  .\n...\n206 files, 9.3 MiB\n\n$ rdu --apparent /tmp/du_demo\n   8.5 MiB  100.0%  ####################  .\n   8.4 MiB   98.6%  ####################  media\n   117.3 KiB    1.3%                      src\n    8.0 KiB    0.1%                      logs\n        64 B    0.0%                      empty\n206 files, 8.5 MiB</code></pre>\n<p>The <code>logs</code> directory holds 200 files of about eight bytes each, so its apparent size is 8.0 KiB (the files plus the directory entries). Its disk usage is far larger, because 200 files each occupy at least one 4 KiB block: the disk-usage mode reports <code>logs</code> at 1.5 MiB, a hundred and ninety times the apparent size. This is exactly the case where the two measures disagree, and the one that explains most \"where did my disk go\" puzzles: a directory with a hundred thousand small files is cheap in bytes and expensive in blocks. Always show the disk usage by default and offer the other as a flag.</p>\n<h2>Two experiments that show why the modes exist</h2>\n<p>The demo tree hides the two cases where apparent and disk size diverge most. A second tree makes them obvious: a 1 GiB sparse file created with <code>truncate -s 1G</code> (all hole, no data), a 1 MB file of random bytes, and a directory of 1,000 files that each contain a single byte.</p>\n<pre><code class=\"language-text\">$ rdu /tmp/du_sparse -d 1\n   4.9 MiB  100.0%  ####################  .\n     3.9 MiB   80.3%  ################     many\n1002 files, 4.9 MiB\n\n$ rdu --apparent /tmp/du_sparse -d 1\n   1.0 GiB  100.0%  ####################  .\n    32.3 KiB    0.0%                       many\n1002 files, 1.0 GiB</code></pre>\n<p>The same directory is 4.9 MiB by one measure and 1.0 GiB by the other. The sparse file contributes a gigabyte of apparent size and nothing at all to disk usage: <code>stat</code> confirms it, reporting 1,073,741,824 bytes and zero blocks. This is why a virtual machine image or a database preallocated with holes looks enormous in <code>ls -l</code> and costs almost nothing on disk, and why copying such a file naively with a tool that does not preserve holes fills the disk. In the other direction, the 1,000 single-byte files total 32.3 KiB of content, but each occupies a full allocation of 8 blocks of 512 bytes, 4,096 bytes, so the directory costs 3.9 MiB, which is 125 times its apparent size. <code>du -sk</code> agreed with the disk-usage figure, reporting 4,980 KiB for the tree. A tool that reports only apparent size would miss the many-small-files case, and one that reports only disk size would miss the sparse file; offer both, default to the one that answers the question \"what is using my disk\".</p>\n<h2>What counts as a file</h2>\n<p>The scanner makes a small number of decisions about file types, and each one should be stated because users hit them. The table lists the cases with the behavior of <code>rdu</code> and the reason.</p>\n<table><thead><tr><th>Entry</th><th>What <code>rdu</code> does</th><th>Why</th></tr></thead><tbody><tr><td>Regular file</td><td>Charges its size once</td><td>The normal case</td></tr><tr><td>Directory</td><td>Charges its own size plus its contents</td><td>A directory occupies blocks for its entries</td></tr><tr><td>Symlink</td><td>Charges the link itself and does not follow it</td><td>Following can loop or double-count</td></tr><tr><td>Hard link, second name</td><td>Charges nothing; the first path in sorted order owns the bytes</td><td>Bytes exist once</td></tr><tr><td>Device or socket</td><td>Charges the metadata size, usually zero</td><td>They hold no data</td></tr><tr><td>Unreadable directory</td><td>Reports an error, continues</td><td>One failure should not hide the rest</td></tr></tbody></table>\n<p>Real tools expose some of these as options: <code>du -L</code> follows symlinks, <code>-l</code> counts hard links every time they appear, and <code>-x</code> refuses to cross file systems. Which to choose depends on the question. If you are asking \"how much would I free by deleting this directory?\", hard links to files that live elsewhere do not count, because deleting this name leaves the bytes in place. That is a harder question than it looks: the correct answer for one directory depends on what else links to its files, which a single scan from that directory cannot know. A faithful \"what would deleting free\" mode scans the whole file system, counts links per inode globally and charges a directory only for inodes whose every link lies inside it.</p>\n<h2>The scanner</h2>\n<p>The scan is a recursive function that returns an <code>Entry</code> for each path: its total size, the number of files below it and, for directories, the child entries. It uses <code>symlink_metadata</code>, which does not follow symlinks, because following them would loop on a link that points at its own parent and would double-count anything reachable twice.</p>\n<pre data-filename=\"src/scan.rs\"><code class=\"language-rust\">use std::collections::HashSet;\nuse std::fs;\nuse std::os::unix::fs::MetadataExt;\nuse std::path::{Path, PathBuf};\nuse std::sync::Mutex;\n\nuse rayon::prelude::*;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum Measure {\n    /// Bytes of content, what `ls -l` shows.\n    Apparent,\n    /// Bytes allocated on disk, what `du` shows: whole blocks, so small files cost a block each.\n    Disk,\n}\n\n#[derive(Debug)]\npub struct Entry {\n    pub path: PathBuf,\n    pub size: u64,\n    pub files: u64,\n    pub children: Vec&lt;Entry&gt;,\n}\n\n#[derive(Default)]\npub struct Stats {\n    pub errors: Mutex&lt;Vec&lt;String&gt;&gt;,\n    /// Files with more than one link. They are not charged during the scan, because threads reach\n    /// them in a different order each run. `settle` charges each one to its first path, sorted.\n    hard_links: Mutex&lt;Vec&lt;(u64, u64, PathBuf, u64)&gt;&gt;,\n}\n\n/// Charge every hard-linked file exactly once, to the path that sorts first, so the report does\n/// not depend on which thread got there first.\npub fn settle(root: &amp;mut Entry, stats: &amp;Stats) {\n    let mut claims = std::mem::take(&amp;mut *stats.hard_links.lock().unwrap());\n    claims.sort_by(|a, b| a.2.cmp(&amp;b.2));\n    let mut charged = HashSet::new();\n    for (dev, ino, path, size) in claims {\n        if charged.insert((dev, ino)) {\n            add_to_ancestors(root, &amp;path, size);\n        }\n    }\n}\n\nfn add_to_ancestors(entry: &amp;mut Entry, path: &amp;Path, size: u64) {\n    if path.starts_with(&amp;entry.path) {\n        entry.size += size;\n        for child in &amp;mut entry.children {\n            add_to_ancestors(child, path, size);\n        }\n        entry.children.sort_by(|a, b| b.size.cmp(&amp;a.size));\n    }\n}\n\nfn measure(md: &amp;fs::Metadata, how: Measure) -&gt; u64 {\n    match how {\n        Measure::Apparent =&gt; md.len(),\n        Measure::Disk =&gt; md.blocks() * 512,\n    }\n}\n\n/// Total size of `path`, with one `Entry` per directory down to `depth` levels.\npub fn scan(path: &amp;Path, how: Measure, depth: usize, stats: &amp;Stats) -&gt; Entry {\n    let md = match fs::symlink_metadata(path) {\n        Ok(md) =&gt; md,\n        Err(e) =&gt; {\n            stats.errors.lock().unwrap().push(format!(\"{}: {e}\", path.display()));\n            return Entry { path: path.into(), size: 0, files: 0, children: vec![] };\n        }\n    };\n    // A symlink is counted as the link itself and never followed, which avoids loops.\n    if !md.is_dir() {\n        let size = measure(&amp;md, how);\n        if md.nlink() &gt; 1 &amp;&amp; !md.file_type().is_symlink() {\n            stats.hard_links.lock().unwrap().push((md.dev(), md.ino(), path.to_path_buf(), size));\n            return Entry { path: path.into(), size: 0, files: 1, children: vec![] };\n        }\n        return Entry { path: path.into(), size, files: 1, children: vec![] };\n    }\n\n    let names: Vec&lt;PathBuf&gt; = match fs::read_dir(path) {\n        Ok(rd) =&gt; rd.filter_map(|e| e.ok()).map(|e| e.path()).collect(),\n        Err(e) =&gt; {\n            stats.errors.lock().unwrap().push(format!(\"{}: {e}\", path.display()));\n            vec![]\n        }\n    };\n    let mut kids: Vec&lt;Entry&gt; = names.par_iter().map(|p| scan(p, how, depth.saturating_sub(1), stats)).collect();\n    let size = measure(&amp;md, how) + kids.iter().map(|k| k.size).sum::&lt;u64&gt;();\n    let files = kids.iter().map(|k| k.files).sum();\n    // Keep only directories as children, and only down to the requested depth.\n    kids.retain(|k| !k.children.is_empty() || fs::symlink_metadata(&amp;k.path).is_ok_and(|m| m.is_dir()));\n    if depth == 0 {\n        kids.clear();\n    }\n    kids.sort_by(|a, b| b.size.cmp(&amp;a.size));\n    Entry { path: path.into(), size, files, children: kids }\n}\n\npub fn human(bytes: u64) -&gt; String {\n    const UNITS: [&amp;str; 5] = [\"B\", \"KiB\", \"MiB\", \"GiB\", \"TiB\"];\n    let mut value = bytes as f64;\n    let mut unit = 0;\n    while value &gt;= 1024.0 &amp;&amp; unit &lt; UNITS.len() - 1 {\n        value /= 1024.0;\n        unit += 1;\n    }\n    if unit == 0 { format!(\"{bytes} B\") } else { format!(\"{value:.1} {}\", UNITS[unit]) }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::os::unix::fs::symlink;\n\n    fn tree() -&gt; PathBuf {\n        static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);\n        let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);\n        let root = std::env::temp_dir().join(format!(\"rdu-test-{}-{n}\", std::process::id()));\n        let _ = fs::remove_dir_all(&amp;root);\n        fs::create_dir_all(root.join(\"a\")).unwrap();\n        fs::create_dir_all(root.join(\"b\")).unwrap();\n        root\n    }\n\n    fn run(root: &amp;Path, how: Measure) -&gt; Entry {\n        let stats = Stats::default();\n        let mut e = scan(root, how, 1, &amp;stats);\n        settle(&amp;mut e, &amp;stats);\n        e\n    }\n\n    #[test]\n    fn apparent_size_adds_up_file_contents() {\n        let root = tree();\n        fs::write(root.join(\"a/one\"), vec![0u8; 1000]).unwrap();\n        fs::write(root.join(\"b/two\"), vec![0u8; 2500]).unwrap();\n        let e = run(&amp;root, Measure::Apparent);\n        let dirs = fs::symlink_metadata(&amp;root).unwrap().len() + fs::symlink_metadata(root.join(\"a\")).unwrap().len() + fs::symlink_metadata(root.join(\"b\")).unwrap().len();\n        assert_eq!(e.size, 3500 + dirs);\n        assert_eq!(e.files, 2);\n        fs::remove_dir_all(root).unwrap();\n    }\n\n    #[test]\n    fn a_hard_link_is_charged_once_and_to_the_first_path() {\n        let root = tree();\n        fs::write(root.join(\"b/big\"), vec![0u8; 100_000]).unwrap();\n        fs::hard_link(root.join(\"b/big\"), root.join(\"a/alias\")).unwrap();\n        for _ in 0..20 {\n            let e = run(&amp;root, Measure::Apparent);\n            let a = e.children.iter().find(|c| c.path.ends_with(\"a\")).unwrap();\n            let b = e.children.iter().find(|c| c.path.ends_with(\"b\")).unwrap();\n            assert!(a.size &gt;= 100_000, \"the first sorted path owns the bytes\");\n            assert!(b.size &lt; 100_000);\n        }\n        fs::remove_dir_all(root).unwrap();\n    }\n\n    #[test]\n    fn a_symlink_loop_terminates_and_is_not_followed() {\n        let root = tree();\n        symlink(&amp;root, root.join(\"a/loop\")).unwrap();\n        let e = run(&amp;root, Measure::Apparent);\n        assert_eq!(e.files, 1); // only the link itself\n        fs::remove_dir_all(root).unwrap();\n    }\n\n    #[test]\n    fn an_unreadable_directory_is_reported_not_fatal() {\n        use std::os::unix::fs::PermissionsExt;\n        let root = tree();\n        fs::write(root.join(\"a/ok\"), b\"x\").unwrap();\n        fs::set_permissions(root.join(\"b\"), fs::Permissions::from_mode(0o000)).unwrap();\n        let stats = Stats::default();\n        let e = scan(&amp;root, Measure::Apparent, 1, &amp;stats);\n        assert_eq!(e.files, 1);\n        assert_eq!(stats.errors.lock().unwrap().len(), 1);\n        fs::set_permissions(root.join(\"b\"), fs::Permissions::from_mode(0o755)).unwrap();\n        fs::remove_dir_all(root).unwrap();\n    }\n}</code></pre>\n<p>Several choices in that listing are the substance of the tool.</p>\n<p>A symlink is counted as the link itself, a few dozen bytes, and never followed. A directory is read with <code>read_dir</code>, and an entry that fails to read is recorded in a shared error list and treated as empty, so one permission problem cannot abort a whole scan. Failure is information to report at the end, not an exception.</p>\n<p>The directory's own size is added to the total of its children, which is what <code>du</code> does: a directory is a file with contents of its own. On this APFS volume empty directories have no blocks, so <code>empty</code> reports <code>0 B</code> in disk mode and 64 B apparent.</p>\n<p>Each directory scans its children in parallel with <code>par_iter</code> from <code>rayon</code>, which hands the children to a thread pool sized to the number of cores. Because <code>scan</code> is a pure recursive function that returns values, the parallelism is a one-word change from <code>iter</code> to <code>par_iter</code>. The only shared state is the <code>Stats</code> object, behind a <code>Mutex</code>, and it is touched only on errors and for hard links, both rare.</p>\n<h2>The bug that only shows on the twelfth run</h2>\n<p>The first version handled hard links the obvious way. A file can have several names, two directory entries pointing at one inode, and <code>du</code> charges its bytes once. The scanner kept a <code>HashSet</code> of <code>(device, inode)</code> pairs and counted a file only when it was inserted for the first time. That is correct for the total. Run the tool twelve times on the demo tree, in which <code>media/video.bin</code> (800 KB) has a hard link in <code>logs</code>, and look at how the two directories were charged:</p>\n<pre><code class=\"language-text\">  1      7.6 MiB media    1.5 MiB logs\n 11      8.4 MiB media    800.0 KiB logs</code></pre>\n<p>Eleven runs charged the 800 KB file to <code>media</code>, and one charged it to <code>logs</code>. The totals were always equal, 9.3 MiB, but the per-directory numbers changed from one run to the next. The cause is the thread pool. Both directories are scanned concurrently, and whichever thread reached the shared inode first inserted it and paid. This is a classic parallel-programming trap: nothing is wrong with the locking, and the result is still not a function of the input.</p>\n<p><code>du</code> has no such problem because it walks the tree in order, so the file is charged to whichever path comes first in its traversal. In the sample, that is <code>logs</code>: <code>du</code> reported <code>logs</code> at 1,584 KiB and <code>media</code> at 7,816 KiB, the same split as the corrected tool.</p>\n<p>The fix makes the choice explicit. Files with more than one link are not charged during the scan; their claims are recorded in <code>Stats</code>. After the scan, <code>settle</code> sorts the claims by path, keeps the first claim for each inode, and adds the bytes to that path and every ancestor directory's entry:</p>\n<pre data-filename=\"src/scan.rs\"><code class=\"language-rust\">pub fn settle(root: &amp;mut Entry, stats: &amp;Stats) {\n    let mut claims = std::mem::take(&amp;mut *stats.hard_links.lock().unwrap());\n    claims.sort_by(|a, b| a.2.cmp(&amp;b.2));\n    let mut charged = HashSet::new();\n    for (dev, ino, path, size) in claims {\n        if charged.insert((dev, ino)) {\n            add_to_ancestors(root, &amp;path, size);\n        }\n    }\n}</code></pre>\n<p>With that change, thirty consecutive runs produced one result, <code>7.6 MiB media</code> and <code>1.5 MiB logs</code>, matching <code>du</code>. The principle is worth stating plainly: when the order of work is not deterministic, make the rule that depends on order explicit and deterministic, here \"the path that sorts first\". Do not rely on timing being stable.</p>\n<h2>Reading the output</h2>\n<p>The report prints each directory with its size, share of the total and a bar scaled to 5 percent per <code>#</code>. Sorting by size puts the answer at the top:</p>\n<pre><code class=\"language-text\">   9.3 MiB  100.0%  ####################  .\n     7.6 MiB   82.1%  ################     media\n       7.6 MiB   82.1%  ################     media/photos\n     1.5 MiB   16.6%  ###                  logs\n   120.0 KiB    1.3%                       src\n         0 B    0.0%                       empty\n206 files, 9.3 MiB</code></pre>\n<p>With <code>-d 2</code>, the tool descends two levels and shows that <code>media/photos</code> holds nearly all of <code>media</code>. The <code>human</code> function formats with binary units, KiB, MiB and GiB, because the file system allocates in powers of two and because <code>KB</code> is ambiguous between 1,000 and 1,024 bytes. The final line gives the file count, which for the sample is 206: 200 logs, three media files, the hard link and the source file, with the symlink counted once.</p>\n<p>The program exits with status 1 if any entry could not be read, and prints the first failure to standard error. A scan of a directory with a locked subdirectory reports the readable part and then complains:</p>\n<pre><code class=\"language-text\">   4.0 KiB  100.0%  ####################  .\n     4.0 KiB  100.0%  ####################  open\n         0 B    0.0%                       locked\n1 files, 4.0 KiB\nrdu: 1 entries could not be read, for example /tmp/du_perm/locked: Permission denied (os error 13)</code></pre>\n<p>A report that silently omits what it could not read is worse than none, because the totals look authoritative. The error line and the exit status make the gap visible to a script.</p>\n<h2>Checking it against du</h2>\n<p>A tool like this is only trustworthy once it agrees with the established one on real data. The test tree was a Cargo <code>target</code> directory of 25,350 files. <code>du -sk</code> reported 10,959,208 KiB, which is 10.45 GiB; <code>rdu</code> reported 10.5 GiB (it rounds to one decimal place), and the file count of 25,350 matched <code>find -type f | wc -l</code>. Speed, measured with <code>time</code>:</p>\n<ul><li><code>rdu</code> with the thread pool: 0.10 to 0.11 s (one 0.17 s run while the cache warmed).</li><li><code>rdu</code> with <code>iter</code> in place of <code>par_iter</code>: 0.20 s.</li><li><code>du -sk</code>: 0.09 to 0.10 s.</li></ul>\n<p>The parallel scan is twice as fast as the sequential one on this machine, and equal to <code>du</code> within noise. The directory metadata was in the operating system's cache, so these are warm-cache numbers; on a cold disk or a network file system, where each <code>stat</code> waits, parallelism helps more, because threads overlap the waiting. The lesson matches the one in <a href=\"/library/rust-build-file-finder\">building a fast file finder</a>: walking a tree is dominated by system calls, and the cheap win is overlapping them.</p>\n<h2>Tests that guard the sharp edges</h2>\n<p>The four unit tests build a real directory tree under the system temp directory and assert the properties that went wrong or could go wrong:</p>\n<pre><code class=\"language-text\">test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s</code></pre>\n<p>One checks that apparent sizes add up, including the directories' own sizes. One creates a hard link and runs the scan twenty times, asserting each time that the directory sorting first owns the bytes: the regression test for the nondeterminism. One creates a symlink that points at its own parent and asserts the scan terminates and counts only the link. One removes all permissions from a subdirectory and asserts that the scan finishes, reports one error, and counts the readable part. Each fixture cleans up after itself, uses the process id in its name so parallel test runs do not collide, and restores permissions before deleting.</p>\n<h2>Where to take it</h2>\n<p>The natural next steps each teach something. A <code>-x</code> flag that stays on one file system compares <code>dev</code> from <code>MetadataExt</code> with the root's and skips directories on other devices, so a scan of <code>/</code> does not wander into network mounts. An <code>--exclude</code> pattern filters paths with the <code>globset</code> crate before descending. A <code>--json</code> output serializes the <code>Entry</code> tree with <code>serde</code> for other tools. An interactive browser built with <code>ratatui</code> (<a href=\"/library/rust-ratatui-tui\">a terminal UI guide</a> covers the loop) lets you move into a directory, sort differently and press a key to delete. And on Linux, reading <code>getdents</code> directly with the <code>rustix</code> crate avoids allocating a path for every entry and makes the scan notably faster on huge trees.</p>\n<h2>When to use the real tools</h2>\n<p>For a one-off, <code>du -sh * | sort -h</code> is faster to type than any program, and <code>ncdu</code> has a polished interface and years of edge-case fixes. Write your own when you need the numbers inside another tool, as a library function in a deployment script or a service that watches a cache directory, or when you need a policy <code>du</code> lacks: charging by owner, grouping by extension, ignoring a build directory. The structure here, a pure recursive function with a small shared error log, a deterministic rule for the shared inode, and a test for each sharp edge, is the one to copy.</p>\n<h2>Making the scanner reusable</h2>\n<p>As written, <code>rdu</code> is one binary with the scanner in a module. The first request after it works is \"can I call this from another program\", and the structure to adopt before that arrives is a library crate with a thin binary: move <code>scan.rs</code> into <code>src/lib.rs</code>, keep the command line in <code>src/main.rs</code>, and let the binary depend on the library like any other crate. The tests move with the scanner, and a second consumer, such as a monitoring service that alerts when a cache directory passes a size, gets <code>scan</code>, <code>settle</code> and <code>Measure</code> without the argument parser.</p>\n<p>Two API choices pay off then. Return the error list as part of the result instead of collecting it in a shared <code>Stats</code> that callers must create; a function that returns <code>(Entry, Vec&lt;String&gt;)</code> is harder to misuse than one that needs a mutable accumulator and a separate call to <code>settle</code>. And hide the two-step scan then settle behind one function, <code>scan_tree</code>, so no caller can forget the second step and get the racy answer the first version produced. A bug you fixed once should be impossible to reintroduce by calling the API wrongly, which is a design rule worth applying to every library in this series, and the one that makes the earlier regression test, twenty runs asserting one answer, a safety net and not the only defense.</p>","date_published":"2026-11-03T11:16:00+00:00","tags":["CLI"]},{"id":"https://rusthacks.dev/library/rust-build-fuzzy-finder","url":"https://rusthacks.dev/library/rust-build-fuzzy-finder","title":"Build a Fuzzy Finder in Rust With Crossterm and Nucleo","summary":"A fuzzy finder reads lines, filters them as you type and prints the one you pick. The pieces are raw mode input, a matching engine and a small redraw loop.","content_html":"<p>A fuzzy finder is a small program that changed how people use a terminal. You pipe it a list, a thousand file names, your command history, a hundred branches, type a few letters from anywhere in the item you want, and it narrows the list live and hands back your choice. <code>fzf</code> made this a habit. Building one is a good Rust project because it splits cleanly into two halves that need different skills: an engine that ranks items against a query, and a terminal interface that draws results and reacts to keys. The engine is pure and testable. The interface is where the platform surprises live, and this guide hits one of them on macOS.</p>\n<p>The finder here, <code>rfz</code>, uses <code>nucleo-matcher</code> 0.3.1 for scoring and <code>crossterm</code> 0.29.0 for the terminal. It was built and tested on Rust 1.99 (edition 2024) on macOS. The interface was exercised end to end through a pseudo-terminal, not by hand, and every output below is real.</p>\n<h2>What fuzzy matching is</h2>\n<p>A fuzzy match asks whether the characters of the query appear in the candidate in order, not necessarily next to each other. The query <code>smr</code> matches <code>src/main.rs</code> because <code>s</code>, <code>m</code> and <code>r</code> all occur, in that order. That is a subsequence match, and by itself it is too loose to be useful: it matches nearly everything long enough. The value is in the ranking, which scores a match higher when it looks like what a person meant. Consecutive characters score more than scattered ones. A match at the start of a word, after a <code>/</code>, <code>_</code> or <code>-</code>, or at a capital letter inside <code>camelCase</code>, scores more than one in the middle of a word. A match in the file name scores more than one in a directory. Shorter candidates win ties.</p>\n<p>You do not write that scoring. <code>nucleo-matcher</code> implements it, the same family of algorithm that powers the Helix editor, and exposes it through <code>Pattern</code> and <code>Matcher</code>. What you write is everything around it, and that is enough work to fill an evening.</p>\n<h2>The engine</h2>\n<p>The engine takes a list of strings and a query and returns ranked hits, each carrying the index of the item, its score and the positions of the matched characters, which the interface needs for highlighting.</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">//! The engine of a fuzzy finder: matching, ranking and the state a UI drives.\n\nuse crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\nuse nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};\nuse nucleo_matcher::{Config, Matcher, Utf32Str};\n\n/// One ranked result: the index of the item, its score and the matched character positions.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct Hit {\n    pub index: usize,\n    pub score: u32,\n    pub positions: Vec&lt;u32&gt;,\n}\n\npub struct Finder {\n    matcher: Matcher,\n    items: Vec&lt;String&gt;,\n}\n\nimpl Finder {\n    pub fn new(items: Vec&lt;String&gt;) -&gt; Self {\n        // `match_paths` gives bonuses for matches after `/` and at the start of a file name.\n        Finder { matcher: Matcher::new(Config::DEFAULT.match_paths()), items }\n    }\n\n    pub fn len(&amp;self) -&gt; usize {\n        self.items.len()\n    }\n\n    pub fn item(&amp;self, index: usize) -&gt; &amp;str {\n        &amp;self.items[index]\n    }\n\n    /// Rank every item against the query, best first. An empty query matches everything in order.\n    pub fn search(&amp;mut self, query: &amp;str, limit: usize) -&gt; Vec&lt;Hit&gt; {\n        if query.is_empty() {\n            return (0..self.items.len().min(limit)).map(|index| Hit { index, score: 0, positions: vec![] }).collect();\n        }\n        let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);\n        let mut buf = Vec::new();\n        let mut hits = Vec::new();\n        for (index, item) in self.items.iter().enumerate() {\n            let haystack = Utf32Str::new(item, &amp;mut buf);\n            let mut positions = Vec::new();\n            if let Some(score) = pattern.indices(haystack, &amp;mut self.matcher, &amp;mut positions) {\n                positions.sort_unstable();\n                positions.dedup();\n                hits.push(Hit { index, score, positions });\n            }\n        }\n        // Higher score first; ties keep the original order, which makes results stable.\n        hits.sort_by(|a, b| b.score.cmp(&amp;a.score).then(a.index.cmp(&amp;b.index)));\n        hits.truncate(limit);\n        hits\n    }\n}\n\nimpl Finder {\n    /// Rank only the items named in `candidates`. When a query grows by one character, every\n    /// match must already be among the previous hits, so the search can start from those.\n    pub fn search_among(&amp;mut self, query: &amp;str, candidates: &amp;[usize], limit: usize) -&gt; Vec&lt;Hit&gt; {\n        let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);\n        let mut buf = Vec::new();\n        let mut hits = Vec::new();\n        for &amp;index in candidates {\n            let haystack = Utf32Str::new(&amp;self.items[index], &amp;mut buf);\n            let mut positions = Vec::new();\n            if let Some(score) = pattern.indices(haystack, &amp;mut self.matcher, &amp;mut positions) {\n                positions.sort_unstable();\n                positions.dedup();\n                hits.push(Hit { index, score, positions });\n            }\n        }\n        hits.sort_by(|a, b| b.score.cmp(&amp;a.score).then(a.index.cmp(&amp;b.index)));\n        hits.truncate(limit);\n        hits\n    }\n\n    /// Same result as `search`, using every core: split the items, rank each chunk with its own\n    /// matcher, and merge the best.\n    pub fn search_parallel(&amp;self, query: &amp;str, limit: usize, threads: usize) -&gt; Vec&lt;Hit&gt; {\n        let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);\n        let chunk = self.items.len().div_ceil(threads.max(1));\n        let mut all: Vec&lt;Hit&gt; = std::thread::scope(|scope| {\n            let handles: Vec&lt;_&gt; = self\n                .items\n                .chunks(chunk.max(1))\n                .enumerate()\n                .map(|(n, slice)| {\n                    let pattern = &amp;pattern;\n                    scope.spawn(move || {\n                        let mut matcher = Matcher::new(Config::DEFAULT.match_paths());\n                        let mut buf = Vec::new();\n                        let mut local = Vec::new();\n                        for (offset, item) in slice.iter().enumerate() {\n                            let mut positions = Vec::new();\n                            if let Some(score) = pattern.indices(Utf32Str::new(item, &amp;mut buf), &amp;mut matcher, &amp;mut positions) {\n                                positions.sort_unstable();\n                                positions.dedup();\n                                local.push(Hit { index: n * chunk + offset, score, positions });\n                            }\n                        }\n                        local.sort_by(|a, b| b.score.cmp(&amp;a.score).then(a.index.cmp(&amp;b.index)));\n                        local.truncate(limit);\n                        local\n                    })\n                })\n                .collect();\n            handles.into_iter().flat_map(|h| h.join().unwrap()).collect()\n        });\n        all.sort_by(|a, b| b.score.cmp(&amp;a.score).then(a.index.cmp(&amp;b.index)));\n        all.truncate(limit);\n        all\n    }\n}\n\n/// What the UI loop should do after a key press.\n#[derive(Debug, PartialEq, Eq)]\npub enum Action {\n    None,\n    Quit,\n    Accept(usize),\n}\n\n/// The state behind the screen: a query, the ranked results and a selected row.\npub struct App {\n    pub finder: Finder,\n    pub query: String,\n    pub hits: Vec&lt;Hit&gt;,\n    pub selected: usize,\n    limit: usize,\n}\n\nimpl App {\n    pub fn new(items: Vec&lt;String&gt;, limit: usize) -&gt; Self {\n        let mut app = App { finder: Finder::new(items), query: String::new(), hits: vec![], selected: 0, limit };\n        app.refresh();\n        app\n    }\n\n    fn refresh(&amp;mut self) {\n        self.hits = self.finder.search(&amp;self.query, self.limit);\n        self.selected = self.selected.min(self.hits.len().saturating_sub(1));\n    }\n\n    pub fn on_key(&amp;mut self, key: KeyEvent) -&gt; Action {\n        match (key.code, key.modifiers) {\n            (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) =&gt; Action::Quit,\n            (KeyCode::Enter, _) =&gt; match self.hits.get(self.selected) {\n                Some(hit) =&gt; Action::Accept(hit.index),\n                None =&gt; Action::None,\n            },\n            (KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) =&gt; {\n                self.selected = self.selected.saturating_sub(1);\n                Action::None\n            }\n            (KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) =&gt; {\n                if self.selected + 1 &lt; self.hits.len() {\n                    self.selected += 1;\n                }\n                Action::None\n            }\n            (KeyCode::Backspace, _) =&gt; {\n                self.query.pop();\n                self.selected = 0;\n                self.refresh();\n                Action::None\n            }\n            (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) =&gt; {\n                self.query.push(c);\n                self.selected = 0;\n                self.refresh();\n                Action::None\n            }\n            _ =&gt; Action::None,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn paths() -&gt; Vec&lt;String&gt; {\n        [\"src/main.rs\", \"src/matcher/mod.rs\", \"src/ui/mod.rs\", \"README.md\", \"tests/main_test.rs\", \"docs/Main.md\", \"Cargo.toml\"]\n            .iter()\n            .map(|s| s.to_string())\n            .collect()\n    }\n\n    fn key(c: KeyCode) -&gt; KeyEvent {\n        KeyEvent::new(c, KeyModifiers::NONE)\n    }\n\n    #[test]\n    fn an_empty_query_lists_everything_in_order() {\n        let mut f = Finder::new(paths());\n        let hits = f.search(\"\", 100);\n        assert_eq!(hits.len(), 7);\n        assert_eq!(hits[0].index, 0);\n    }\n\n    #[test]\n    fn subsequence_matches_rank_the_file_name_hit_first() {\n        let mut f = Finder::new(paths());\n        let hits = f.search(\"main\", 100);\n        let top = f.item(hits[0].index);\n        assert_eq!(top, \"src/main.rs\");\n        assert!(hits.iter().any(|h| f.item(h.index) == \"tests/main_test.rs\"));\n    }\n\n    #[test]\n    fn letters_must_appear_in_order_but_need_not_touch() {\n        let mut f = Finder::new(paths());\n        let found: Vec&lt;_&gt; = f.search(\"smr\", 10).iter().map(|h| f.item(h.index).to_string()).collect();\n        assert!(found.contains(&amp;\"src/main.rs\".to_string()));\n        assert!(f.search(\"nms\", 10).iter().all(|h| f.item(h.index) != \"src/main.rs\"));\n    }\n\n    #[test]\n    fn smart_case_is_insensitive_until_you_type_a_capital() {\n        let mut f = Finder::new(paths());\n        assert!(f.search(\"main\", 10).iter().any(|h| f.item(h.index) == \"docs/Main.md\"));\n        let upper: Vec&lt;_&gt; = f.search(\"Main\", 10).iter().map(|h| f.item(h.index).to_string()).collect();\n        assert_eq!(upper, [\"docs/Main.md\"]);\n    }\n\n    #[test]\n    fn positions_point_at_the_matched_characters() {\n        let mut f = Finder::new(vec![\"src/main.rs\".into()]);\n        let hit = &amp;f.search(\"mn\", 1)[0];\n        let chars: Vec&lt;char&gt; = \"src/main.rs\".chars().collect();\n        let matched: String = hit.positions.iter().map(|&amp;p| chars[p as usize]).collect();\n        assert_eq!(matched, \"mn\");\n    }\n\n    #[test]\n    fn keys_edit_the_query_move_the_selection_and_accept() {\n        let mut app = App::new(paths(), 50);\n        for c in \"main\".chars() {\n            assert_eq!(app.on_key(key(KeyCode::Char(c))), Action::None);\n        }\n        assert_eq!(app.query, \"main\");\n        app.on_key(key(KeyCode::Down));\n        assert_eq!(app.selected, 1);\n        app.on_key(key(KeyCode::Up));\n        app.on_key(key(KeyCode::Up)); // already at the top: stays there\n        assert_eq!(app.selected, 0);\n        let first = app.hits[0].index;\n        assert_eq!(app.on_key(key(KeyCode::Enter)), Action::Accept(first));\n        app.on_key(key(KeyCode::Backspace));\n        assert_eq!(app.query, \"mai\");\n        assert_eq!(app.on_key(key(KeyCode::Esc)), Action::Quit);\n        assert_eq!(app.on_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), Action::Quit);\n    }\n\n    #[test]\n    fn narrowing_and_parallel_search_agree_with_the_full_search() {\n        let items: Vec&lt;String&gt; = (0..2000).map(|i| format!(\"dir{}/file{}_{}.rs\", i % 17, i, i * 7)).collect();\n        let mut f = Finder::new(items);\n        let full = f.search(\"fil1\", 50);\n        let before = f.search(\"fil\", usize::MAX);\n        let candidates: Vec&lt;usize&gt; = before.iter().map(|h| h.index).collect();\n        assert_eq!(f.search_among(\"fil1\", &amp;candidates, 50), full);\n        assert_eq!(f.search_parallel(\"fil1\", 50, 4), full);\n    }\n\n    #[test]\n    fn accepting_with_no_results_does_nothing() {\n        let mut app = App::new(paths(), 50);\n        for c in \"zzzzzz\".chars() {\n            app.on_key(key(KeyCode::Char(c)));\n        }\n        assert!(app.hits.is_empty());\n        assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None);\n    }\n}</code></pre>\n<p>Several choices in <code>Finder::search</code> are worth noticing. The matcher is configured with <code>Config::DEFAULT.match_paths()</code>, which adds bonuses for matches after a path separator, so <code>main</code> prefers <code>src/main.rs</code> over <code>src/domain/model.rs</code>. <code>Pattern::parse</code> with <code>CaseMatching::Smart</code> implements the convention users expect from <code>fzf</code> and <code>ripgrep</code>: the match ignores case until the query contains a capital letter, and then it becomes case-sensitive. An empty query returns everything in the original order, so the screen is useful before the first keystroke. Results sort by score, highest first, with the original index as a tiebreaker, which makes the output stable: two items with the same score always appear in the order they were supplied, so the list does not shuffle as you type. And the search returns at most <code>limit</code> hits, because a screen shows 40 lines, not 250,000.</p>\n<p>Run on a small list of file names, the engine ranks like this:</p>\n<pre><code class=\"language-text\">query \"main\"\n   109  src/[m][a][i][n].rs\n   109  tests/[m][a][i][n]_test.rs\n   109  docs/[M][a][i][n].md\n    76  src/do[m][a][i][n]/model.rs\nquery \"smr\"\n    72  [s]rc/[m]ain.[r]s\n    70  [s]rc/ui/[m]od.[r]s\n    65  [s]rc/[m]atcher/mod.[r]s\n    64  [s]rc/domain/[m]odel.[r]s\nquery \"Main\"\n   109  docs/[M][a][i][n].md\nquery \"mod\"\n    84  src/matcher/[m][o][d].rs\n    84  src/ui/[m][o][d].rs\n    84  src/domain/[m][o][d]el.rs\nquery \"cargo\"\n   134  [C][a][r][g][o].toml</code></pre>\n<p>The brackets mark the characters that matched, which is the <code>positions</code> field. Read the results the way a user would. For <code>main</code>, the three files whose names are <code>main</code> score 109 each and tie, and <code>src/domain/model.rs</code>, where the letters of <code>main</code> appear inside the word <code>domain</code>, scores 76. The scattered query <code>smr</code> finds <code>src/main.rs</code> first, with matches at the start of <code>src</code>, the start of <code>main</code> and the start of the extension. The capital in <code>Main</code> switches off case-insensitivity, so only <code>docs/Main.md</code> qualifies. For <code>mod</code>, three files score the same 84, and the stable sort keeps them in input order. The <code>cargo</code> query puts the exact word first at 134.</p>\n<h2>Tests that pin the behavior</h2>\n<p>Because the engine is a plain struct with no terminal in it, its behavior is easy to specify. Eight tests cover it. Here are the two that expose the most:</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">#[test]\nfn letters_must_appear_in_order_but_need_not_touch() {\n    let mut f = Finder::new(paths());\n    let found: Vec&lt;_&gt; = f.search(\"smr\", 10).iter().map(|h| f.item(h.index).to_string()).collect();\n    assert!(found.contains(&amp;\"src/main.rs\".to_string()));\n    assert!(f.search(\"nms\", 10).iter().all(|h| f.item(h.index) != \"src/main.rs\"));\n}\n\n#[test]\nfn positions_point_at_the_matched_characters() {\n    let mut f = Finder::new(vec![\"src/main.rs\".into()]);\n    let hit = &amp;f.search(\"mn\", 1)[0];\n    let chars: Vec&lt;char&gt; = \"src/main.rs\".chars().collect();\n    let matched: String = hit.positions.iter().map(|&amp;p| chars[p as usize]).collect();\n    assert_eq!(matched, \"mn\");\n}</code></pre>\n<pre><code class=\"language-text\">test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s</code></pre>\n<p>The first test originally used the out-of-order query <code>rms</code> and failed, and it is a good story about fuzzy matching. The test claimed <code>rms</code> should not match <code>src/main.rs</code>, since <code>m</code> comes before <code>r</code> in the word <code>main</code>. But the subsequence is <code>r</code> from <code>src</code>, then <code>m</code> from <code>main</code>, then <code>s</code> from <code>rs</code>, which is in order. The matcher was right and the test was wrong. A query that truly cannot match is <code>nms</code>: the only <code>m</code> in the path comes before the only <code>n</code>, so no <code>m</code> follows an <code>n</code>. When you write tests for a fuzzy matcher, check by hand that your negative examples are genuinely impossible.</p>\n<h2>The state machine: keys in, actions out</h2>\n<p>The interface separates what happens from how it looks. <code>App</code> holds the query, the ranked hits and the selected row, and <code>on_key</code> takes a key event and returns an <code>Action</code>: nothing, quit, or accept a particular item.</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">pub fn on_key(&amp;mut self, key: KeyEvent) -&gt; Action {\n    match (key.code, key.modifiers) {\n        (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) =&gt; Action::Quit,\n        (KeyCode::Enter, _) =&gt; match self.hits.get(self.selected) {\n            Some(hit) =&gt; Action::Accept(hit.index),\n            None =&gt; Action::None,\n        },\n        (KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) =&gt; { /* move up */ Action::None }\n        (KeyCode::Backspace, _) =&gt; { self.query.pop(); self.selected = 0; self.refresh(); Action::None }\n        (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) =&gt; {\n            self.query.push(c);\n            self.selected = 0;\n            self.refresh();\n            Action::None\n        }\n        _ =&gt; Action::None,\n    }\n}</code></pre>\n<p>Because <code>KeyEvent</code> is a plain value you can construct in a test, the whole interaction is testable with no terminal. The test <code>keys_edit_the_query_move_the_selection_and_accept</code> types <code>main</code>, presses Down, Up, Up (which stops at the top), Enter, Backspace, Escape and Ctrl-C, asserting the state after each. Another test checks that pressing Enter with no results does nothing instead of accepting a non-existent item. The state machine is the place where most bugs in an interactive tool live, and having it free of I/O means you find them in milliseconds.</p>\n<p>Two details of the design are worth stating. The selection resets to the top whenever the query changes, because the old position is meaningless in a new list, and <code>refresh</code> clamps it into range when the hits shrink. And Control-modified letters are not typed into the query: the guard <code>!m.contains(KeyModifiers::CONTROL)</code> is what keeps Ctrl-N and Ctrl-P as navigation instead of inserting an <code>n</code> or a <code>p</code>.</p>\n<h2>The terminal layer</h2>\n<p>The interface is thin. It switches the terminal to raw mode and the alternate screen, runs a loop that draws and then waits for a key, and restores the terminal on exit. One design choice makes it usable in a shell. The program draws on standard error and prints the chosen item on standard output, so a shell can capture the selection while the interface still appears on screen:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::io::{self, BufRead, IsTerminal, Write};\nuse std::process::ExitCode;\nuse std::time::Duration;\n\nuse crossterm::cursor::{Hide, MoveTo, Show};\nuse crossterm::event::{self, Event, KeyEventKind};\nuse crossterm::style::{Attribute, Print, SetAttribute};\nuse crossterm::terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};\nuse crossterm::{execute, queue};\nuse rfz::{Action, App, Hit};\n\n/// Draw the prompt and the visible results. The screen is written to stderr so that the chosen\n/// item can go to stdout and be captured by a shell: `vim \"$(find . -type f | rfz)\"`.\nfn draw(out: &amp;mut impl Write, app: &amp;App, rows: u16) -&gt; io::Result&lt;()&gt; {\n    queue!(out, MoveTo(0, 0), Clear(ClearType::All), Print(format!(\"&gt; {}\", app.query)))?;\n    for (row, hit) in app.hits.iter().take(rows.saturating_sub(2) as usize).enumerate() {\n        queue!(out, MoveTo(0, row as u16 + 1))?;\n        queue!(out, Print(if row == app.selected { \"&gt; \" } else { \"  \" }))?;\n        print_highlighted(out, app.finder.item(hit.index), hit)?;\n    }\n    out.flush()\n}\n\nfn print_highlighted(out: &amp;mut impl Write, text: &amp;str, hit: &amp;Hit) -&gt; io::Result&lt;()&gt; {\n    for (i, ch) in text.chars().enumerate() {\n        if hit.positions.contains(&amp;(i as u32)) {\n            queue!(out, SetAttribute(Attribute::Bold), Print(ch), SetAttribute(Attribute::Reset))?;\n        } else {\n            queue!(out, Print(ch))?;\n        }\n    }\n    Ok(())\n}\n\nfn run(items: Vec&lt;String&gt;) -&gt; io::Result&lt;Option&lt;String&gt;&gt; {\n    let mut app = App::new(items, 200);\n    let mut screen = io::stderr();\n    terminal::enable_raw_mode()?;\n    execute!(screen, EnterAlternateScreen, Hide)?;\n    let result = (|| -&gt; io::Result&lt;Option&lt;String&gt;&gt; {\n        loop {\n            let (_, rows) = terminal::size()?;\n            draw(&amp;mut screen, &amp;app, rows)?;\n            if !event::poll(Duration::from_millis(250))? {\n                continue;\n            }\n            if let Event::Key(key) = event::read()? {\n                if key.kind != KeyEventKind::Press {\n                    continue;\n                }\n                match app.on_key(key) {\n                    Action::Quit =&gt; return Ok(None),\n                    Action::Accept(i) =&gt; return Ok(Some(app.finder.item(i).to_string())),\n                    Action::None =&gt; {}\n                }\n            }\n        }\n    })();\n    // Restore the terminal on every path, including errors.\n    execute!(screen, Show, LeaveAlternateScreen)?;\n    terminal::disable_raw_mode()?;\n    result\n}\n\nfn main() -&gt; ExitCode {\n    if io::stdin().is_terminal() {\n        eprintln!(\"rfz: pipe a list of candidates into it, for example: find . -type f | rfz\");\n        return ExitCode::from(2);\n    }\n    let items: Vec&lt;String&gt; = io::stdin().lock().lines().map_while(Result::ok).filter(|l| !l.is_empty()).collect();\n    match run(items) {\n        Ok(Some(choice)) =&gt; {\n            println!(\"{choice}\");\n            ExitCode::SUCCESS\n        }\n        Ok(None) =&gt; ExitCode::from(130),\n        Err(e) =&gt; {\n            eprintln!(\"rfz: {e}\");\n            ExitCode::from(2)\n        }\n    }\n}</code></pre>\n<p>That is the same trick <code>fzf</code> uses, and it makes this idiom work: <code>vim \"$(find . -type f | rfz)\"</code>. Standard input carries the candidates, standard output carries the result, and the terminal itself, opened separately, carries the interaction. The exit status distinguishes the cases: 0 for a choice, 130 for a cancel, matching the shell convention for an interrupt, and 2 for errors. The restore call runs after the inner loop returns whether it succeeded or failed, so a failure never leaves the user's terminal in raw mode; a terminal left in raw mode looks like a shell that has stopped echoing your typing.</p>\n<h3>The error that only appears when stdin is a pipe</h3>\n<p>The first build of this program compiled, passed its tests, and failed the first time it ran as a real finder, which is the only time the problem can show. Run under a pseudo-terminal with candidates piped into it, it printed:</p>\n<pre><code class=\"language-text\">rfz: Failed to initialize input reader</code></pre>\n<p>The cause is a platform detail. A finder reads its candidates from standard input, so standard input is a pipe. Then it must read keystrokes from somewhere else, and the only place left is the controlling terminal, <code>/dev/tty</code>. On Linux, <code>crossterm</code> reads keys from the terminal device when stdin is not a terminal. On macOS it uses a mechanism that cannot poll <code>/dev/tty</code>, and initializing the event reader fails. The fix is a crate feature that opts into a different reader that opens <code>/dev/tty</code> directly:</p>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[dependencies]\ncrossterm = { version = \"0.29\", features = [\"use-dev-tty\", \"libc\"] }\nnucleo-matcher = \"0.3\"\nanyhow = \"1\"</code></pre>\n<p>The lesson generalizes: a terminal interface that also consumes piped input needs a keyboard source that is independent of stdin. Always test the real combination, <code>producer | tool</code>, in a real terminal, because a unit test of the state machine will not catch it.</p>\n<h2>Testing the real thing through a pseudo-terminal</h2>\n<p>A tool that reads a terminal can be tested end to end by giving it a pseudo-terminal and playing the user. Python's <code>pty</code> module does it in a few lines: fork with a pty as the controlling terminal, run a shell command that pipes four candidate paths into <code>rfz</code> and echoes the result, send the keystrokes <code>b</code>, <code>e</code>, <code>t</code>, the Down arrow and Enter, and read what comes out. The run printed the screen redraws as escape sequences, eleven of them as the keys arrived, and then the shell's echo of the choice:</p>\n<pre><code class=\"language-text\">11 screens drawn\nchose: docs/better.md</code></pre>\n<p>The candidates were <code>src/alpha.rs</code>, <code>src/beta.rs</code>, <code>docs/better.md</code> and <code>README.md</code>. Typing <code>bet</code> leaves two matches, <code>src/beta.rs</code> and <code>docs/better.md</code>; Down moved the selection to the second; Enter returned it. The whole chain, key decoding, query editing, matching, selection and the output on stdout captured by <code>$(...)</code>, worked. Keep a script like this in the repository; it is the only test that exercises the terminal setup and teardown.</p>\n<h2>How fast is the engine?</h2>\n<p>An interactive finder has about 50 milliseconds before the delay is noticeable. The benchmark used 253,500 paths, built by repeating a real listing of 25,350 files from a Cargo <code>target</code> directory ten times with different prefixes, and ran each query once from scratch in a release build on an Apple silicon laptop:</p>\n<pre><code class=\"language-text\">253500 paths (25350 unique shapes)\n         m: 20.8ms\n      main: 37.7ms\n   src/lib: 23.1ms\n serdejson: 19.2ms\n     xyzzy: 10.9ms</code></pre>\n<p>Every query finished well under 50 ms on a quarter of a million paths with one thread, which is fast enough that the simple design, re-scoring everything on every keystroke, is the right one to ship first. The slowest query, <code>main</code>, matched the most items and so did the most scoring and sorting. A query that matches nothing is the fastest, but notice the last row: <code>xyzzy</code> does match something in this data, because the list contains hash-named build artifacts with long random file names, and a five-letter subsequence appears by chance in a long enough string. That is the practical weakness of fuzzy matching on long, random-looking strings, and it is why the ranking matters more than the match.</p>\n<h2>When the simple design stops being fast enough</h2>\n<p>The search is linear in the number of candidates. Two standard improvements exist, and the measurements show that only one of them helps here, which is why you measure before adding either.</p>\n<p>The first is narrowing. When a new query extends the previous one by a character, every match must already be among the previous hits, so the search can start from those. <code>search_among</code> ranks only the indices you give it. The test <code>narrowing_and_parallel_search_agree_with_the_full_search</code> checks that it returns exactly what a full search returns. Typing <code>serde</code> one key at a time over the 253,500 paths gave these timings, with the full scan and the narrowed search measured for every prefix:</p>\n<pre><code class=\"language-text\">query       matches  full scan   narrowed\ns            241440     25.8ms     27.2ms\nse           214210    120.0ms     91.4ms\nser          167100     92.2ms     93.2ms\nserd          76030     55.2ms     63.1ms\nserde         48130     46.5ms     40.8ms\nserde_        14010     27.7ms     14.7ms</code></pre>\n<p>Narrowing barely helped. The reason is in the second column: paths are long strings, and a short query is a subsequence of almost all of them, so the letter <code>s</code> alone matches 241,440 of 253,500 paths. Narrowing from 241,000 to 214,000 candidates saves nothing worth having. It starts to pay only when the query becomes selective, as in the last row, where it halves the time. The takeaway is not that narrowing is useless, it is that the benefit depends on how selective the first characters are in your data. For short strings such as branch names or commands, short queries are selective and narrowing works well. For long file paths it does not.</p>\n<p>The second improvement is parallelism, and it helps unconditionally because the work is independent per item. <code>search_parallel</code> splits the items into chunks, gives each thread its own <code>Matcher</code> (the matcher holds scratch space and cannot be shared), ranks its chunk, and merges the best from each. The five-keystroke typing sequence on the full list took:</p>\n<pre><code class=\"language-text\">typing s-e-r-d-e over 253500 paths\n  rescan everything each key:    317 to 327 ms\n  narrow from previous hits:     315 to 328 ms\n  8 threads, rescan each key:     62 to  77 ms</code></pre>\n<p>Those are totals for five keystrokes, so the parallel version spent about 13 ms per key against about 64 ms for the single-threaded scan, a speedup of roughly five on this machine. This is the improvement to reach for first. The numbers also show where the ceiling is: the merge and the final sort of all matching items are single-threaded, and with 240,000 matches that sort is a real cost. Keeping only the top <code>limit</code> hits per thread, as the code does, with a bounded heap instead of a full sort, is the next optimization.</p>\n<p>A third change matters more for feel than for raw speed: do the search off the interface thread. If the search takes 60 ms, the screen should still accept keystrokes during it and drop results that were computed for a query the user has since changed. The <code>nucleo</code> crate packages exactly this, with a worker pool, a stream of items that can arrive while searching, and snapshots the interface reads. Move to it when your lists are large or slow to produce. For lists under 100,000 items, the single-threaded engine in this guide is already fast enough to feel instant.</p>\n<h2>Extensions</h2>\n<p>Each of these teaches a distinct part of the problem. A preview pane that shows the first lines of the selected file. Multi-select with Tab, returning several lines. A <code>--query</code> flag to start with text already typed. Highlighting in the drawn result rows, which the code already does with bold text on the <code>positions</code>. Mouse support. Shell key bindings, so Ctrl-R searches history and Ctrl-T inserts a path. And a mode that reads NUL-separated items for paths containing newlines. For the other tools in this family, see <a href=\"/library/rust-build-file-finder\">a fast file finder with parallel walking</a>, which produces the lists you pipe in, and <a href=\"/library/rust-ratatui-tui\">terminal user interfaces with Ratatui</a>, which replaces the hand-written drawing with a layout system when the screen grows beyond a list.</p>\n<h2>Wiring it into a shell</h2>\n<p>A finder earns its keep through the shell bindings around it. The simplest is a function that picks a file and opens it: <code>edit() { local f; f=\"$(find . -type f -not -path './.git/*' | rfz)\" &amp;&amp; ${EDITOR:-vi} \"$f\"; }</code>. The <code>&amp;&amp;</code> is doing real work: when the user cancels with Escape the program exits with status 130, the capture is empty, and the editor is never started. For history search, feed it <code>fc -ln 1</code> and insert the choice into the command line with a key binding in your shell's line editor. For branches, <code>git branch --format='%(refname:short)' | rfz | xargs git checkout</code> is a one-line branch switcher. Each of these works because of the three decisions made earlier: candidates on stdin, the chosen item alone on stdout, and the interface and diagnostics on standard error, so none of the plumbing sees the screen.</p>\n<p>Test the bindings the way you test the program: with a pseudo-terminal and a known candidate list, asserting on the text the shell function receives. A binding that works on your machine and breaks under a different shell or without a controlling terminal, such as inside a cron job or a CI container where no <code>/dev/tty</code> exists, should fail with a clear message and a non-zero status rather than hang waiting for keys that will never come.</p>","date_published":"2026-11-03T11:10:00+00:00","tags":["CLI"]},{"id":"https://rusthacks.dev/library/rust-markdown-cli-pulldown","url":"https://rusthacks.dev/library/rust-markdown-cli-pulldown","title":"Build a Markdown to HTML Converter CLI in Rust With Pulldown","summary":"pulldown-cmark turns Markdown into a stream of events, and its HTML writer renders them. A small CLI adds file handling, options and a page template around it.","content_html":"<p>Converting Markdown to HTML looks like a solved problem, and it is: call one function from a library and you are done. That is also how the interesting parts get missed. A real converter has to decide what to do with raw HTML in the input, which is a security question. It has to give headings stable anchors, rewrite links between pages, and build a table of contents, which are design questions. And it has to behave as a command line tool, reading standard input, writing to a file, and failing with a clear message. This guide builds <code>mdhtml</code>, a Markdown to HTML converter on top of <code>pulldown-cmark</code>, and spends most of its time on the decisions around the one-line conversion.</p>\n<p>The tool compiles and runs on Rust 1.99 (edition 2024) with <code>pulldown-cmark</code> 0.13 and <code>clap</code> 4.6.7. All output blocks below are what the programs printed.</p>\n<h2>What pulldown-cmark gives you: a stream of events</h2>\n<p><code>pulldown-cmark</code> is a pull parser. It does not build a tree. It walks the document and hands you a sequence of events: start of a heading, some text, end of a heading, start of a paragraph. A tiny document makes the shape clear. This program prints the events for a heading, a paragraph with emphasis and a link, and a list:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use pulldown_cmark::{Options, Parser};\n\nfn main() {\n    let markdown = \"# Title\\n\\nA *brief* [link](a.md).\\n\\n- one\\n\";\n    for event in Parser::new_ext(markdown, Options::ENABLE_TABLES) {\n        println!(\"{event:?}\");\n    }\n}</code></pre>\n<pre><code class=\"language-text\">Start(Heading { level: H1, id: None, classes: [], attrs: [] })\nText(Borrowed(\"Title\"))\nEnd(Heading(H1))\nStart(Paragraph)\nText(Borrowed(\"A \"))\nStart(Emphasis)\nText(Borrowed(\"brief\"))\nEnd(Emphasis)\nText(Borrowed(\" \"))\nStart(Link { link_type: Inline, dest_url: Borrowed(\"a.md\"), title: Borrowed(\"\"), id: Borrowed(\"\") })\nText(Borrowed(\"link\"))\nEnd(Link)\nText(Borrowed(\".\"))\nEnd(Paragraph)\nStart(List(None))\nStart(Item)\nText(Borrowed(\"one\"))\nEnd(Item)\nEnd(List(false))</code></pre>\n<p>Every event is a plain enum value, and the text is <code>Borrowed</code>: the parser points into your input instead of copying it. Two consequences matter. The event stream is an ordinary Rust iterator, so you can <code>map</code>, <code>filter</code> and <code>filter_map</code> it, which is how every customization in this guide works. And because nothing is built in memory, converting a megabyte takes a few milliseconds.</p>\n<p>To get HTML you pass the events to <code>html::push_html</code>. The simplest converter is three lines:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">let parser = Parser::new_ext(markdown, Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES);\nlet mut html = String::new();\npulldown_cmark::html::push_html(&amp;mut html, parser);</code></pre>\n<p><code>Options</code> turns on extensions beyond core CommonMark: tables, footnotes, strikethrough, task lists and smart punctuation. They are off by default so that the parser matches the standard unless you ask for more.</p>\n<h2>The decision most converters get wrong: raw HTML</h2>\n<p>Markdown allows raw HTML, and the parser passes it straight through. That is correct for a trusted author writing a blog and dangerous for anything else. Convert this document, which has a script tag, to see what comes out:</p>\n<pre><code class=\"language-text\">&lt;h2 id=\"features\"&gt;Features&lt;/h2&gt;\n...\n&lt;script&gt;alert('hi')&lt;/script&gt;\n&lt;p&gt;Footnotes work too.&lt;sup class=\"footnote-reference\"&gt;&lt;a href=\"#1\"&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;</code></pre>\n<p>The <code>&lt;script&gt;</code> element appears in the output exactly as written. If this text came from a user, a comment, a wiki edit or a pasted README, you have just published a cross-site scripting vulnerability. The converter must offer a safe mode, and the event stream makes one easy to build: raw HTML arrives as <code>Event::Html</code> for blocks and <code>Event::InlineHtml</code> for fragments, so a <code>filter_map</code> that drops both removes it.</p>\n<pre data-filename=\"src/convert.rs\"><code class=\"language-rust\">use pulldown_cmark::{html, CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct Settings {\n    /// Drop raw HTML from the input instead of passing it through.\n    pub safe: bool,\n    /// Rewrite relative links that end in `.md` so they point at the generated `.html` page.\n    pub rewrite_md_links: bool,\n}\n\npub struct Rendered {\n    pub body: String,\n    pub toc: Vec&lt;(HeadingLevel, String, String)&gt;,\n}\n\npub fn options() -&gt; Options {\n    Options::ENABLE_TABLES\n        | Options::ENABLE_FOOTNOTES\n        | Options::ENABLE_STRIKETHROUGH\n        | Options::ENABLE_TASKLISTS\n        | Options::ENABLE_SMART_PUNCTUATION\n}\n\n/// `Rust &amp; WebAssembly: a tour` becomes `rust-webassembly-a-tour`.\npub fn slug(text: &amp;str) -&gt; String {\n    let mut out = String::new();\n    for c in text.chars() {\n        if c.is_alphanumeric() {\n            out.extend(c.to_lowercase());\n        } else if !out.ends_with('-') &amp;&amp; !out.is_empty() {\n            out.push('-');\n        }\n    }\n    out.trim_end_matches('-').to_string()\n}\n\npub fn render(markdown: &amp;str, settings: Settings) -&gt; Rendered {\n    // First pass: find the headings so each one can get an id and a table of contents entry.\n    let mut toc = Vec::new();\n    let mut seen: std::collections::HashMap&lt;String, usize&gt; = std::collections::HashMap::new();\n    let mut current: Option&lt;(HeadingLevel, String)&gt; = None;\n    for event in Parser::new_ext(markdown, options()) {\n        match event {\n            Event::Start(Tag::Heading { level, .. }) =&gt; current = Some((level, String::new())),\n            Event::Text(t) | Event::Code(t) =&gt; {\n                if let Some((_, text)) = current.as_mut() {\n                    text.push_str(&amp;t);\n                }\n            }\n            Event::End(TagEnd::Heading(_)) =&gt; {\n                if let Some((level, text)) = current.take() {\n                    let base = slug(&amp;text);\n                    // A repeated heading gets `-1`, `-2`, ... so anchors stay unique.\n                    let count = seen.entry(base.clone()).or_insert(0);\n                    let id = if *count == 0 { base } else { format!(\"{base}-{count}\") };\n                    *count += 1;\n                    toc.push((level, text, id));\n                }\n            }\n            _ =&gt; {}\n        }\n    }\n\n    // Second pass: transform the event stream, then hand it to the HTML writer.\n    let mut next_heading = 0;\n    let events = Parser::new_ext(markdown, options()).filter_map(|event| match event {\n        Event::Html(_) | Event::InlineHtml(_) if settings.safe =&gt; None,\n        Event::Start(Tag::Heading { level, classes, attrs, .. }) =&gt; {\n            let id = toc.get(next_heading).map(|(_, _, id)| CowStr::from(id.clone()));\n            next_heading += 1;\n            Some(Event::Start(Tag::Heading { level, id, classes, attrs }))\n        }\n        Event::Start(Tag::Link { link_type, dest_url, title, id }) if settings.safe &amp;&amp; !is_safe_url(&amp;dest_url) =&gt; {\n            Some(Event::Start(Tag::Link { link_type, dest_url: CowStr::from(\"#\"), title, id }))\n        }\n        Event::Start(Tag::Image { link_type, dest_url, title, id }) if settings.safe &amp;&amp; !is_safe_url(&amp;dest_url) =&gt; {\n            Some(Event::Start(Tag::Image { link_type, dest_url: CowStr::from(\"\"), title, id }))\n        }\n        Event::Start(Tag::Link { link_type, dest_url, title, id }) if settings.rewrite_md_links =&gt; {\n            let dest_url = match dest_url.strip_suffix(\".md\") {\n                Some(stem) if !dest_url.contains(\"://\") =&gt; CowStr::from(format!(\"{stem}.html\")),\n                _ =&gt; dest_url,\n            };\n            Some(Event::Start(Tag::Link { link_type, dest_url, title, id }))\n        }\n        other =&gt; Some(other),\n    });\n\n    let mut body = String::new();\n    html::push_html(&amp;mut body, events);\n    Rendered { body, toc }\n}\n\n/// Allow web links, mail links and relative paths. Anything with another scheme, such as\n/// `javascript:` or `data:`, is replaced.\nfn is_safe_url(url: &amp;str) -&gt; bool {\n    let trimmed = url.trim_start();\n    match trimmed.split_once(':') {\n        // A colon before any slash or question mark means the text before it is a scheme.\n        Some((scheme, _)) if !scheme.contains(['/', '?', '#']) =&gt; {\n            matches!(scheme.to_ascii_lowercase().as_str(), \"http\" | \"https\" | \"mailto\")\n        }\n        _ =&gt; true,\n    }\n}\n\npub fn page(title: &amp;str, rendered: &amp;Rendered) -&gt; String {\n    let mut nav = String::new();\n    for (level, text, id) in &amp;rendered.toc {\n        let depth = *level as usize;\n        nav.push_str(&amp;format!(\"&lt;li class=\\\"h{depth}\\\"&gt;&lt;a href=\\\"#{id}\\\"&gt;{}&lt;/a&gt;&lt;/li&gt;\\n\", escape(text)));\n    }\n    format!(\n        \"&lt;!doctype html&gt;\\n&lt;html lang=\\\"en\\\"&gt;\\n&lt;head&gt;\\n&lt;meta charset=\\\"utf-8\\\"&gt;\\n&lt;title&gt;{}&lt;/title&gt;\\n&lt;/head&gt;\\n&lt;body&gt;\\n&lt;nav&gt;&lt;ul&gt;\\n{nav}&lt;/ul&gt;&lt;/nav&gt;\\n&lt;main&gt;\\n{}&lt;/main&gt;\\n&lt;/body&gt;\\n&lt;/html&gt;\\n\",\n        escape(title),\n        rendered.body\n    )\n}\n\nfn escape(text: &amp;str) -&gt; String {\n    text.replace('&amp;', \"&amp;amp;\").replace('&lt;', \"&amp;lt;\").replace('&gt;', \"&amp;gt;\").replace('\"', \"&amp;quot;\")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn slugs_drop_punctuation_and_collapse_runs() {\n        assert_eq!(slug(\"Rust &amp; WebAssembly: a tour\"), \"rust-webassembly-a-tour\");\n        assert_eq!(slug(\"  Spaces  \"), \"spaces\");\n    }\n\n    #[test]\n    fn raw_html_passes_through_by_default_and_is_removed_in_safe_mode() {\n        let md = \"hello\\n\\n&lt;script&gt;alert(1)&lt;/script&gt;\\n\\nworld &lt;b onclick=\\\"x()\\\"&gt;bold&lt;/b&gt;\\n\";\n        assert!(render(md, Settings::default()).body.contains(\"&lt;script&gt;\"));\n        let safe = render(md, Settings { safe: true, ..Settings::default() }).body;\n        assert!(!safe.contains(\"&lt;script&gt;\") &amp;&amp; !safe.contains(\"onclick\"));\n        assert!(safe.contains(\"hello\") &amp;&amp; safe.contains(\"world\") &amp;&amp; safe.contains(\"bold\"));\n    }\n\n    #[test]\n    fn safe_mode_neutralizes_script_urls_but_keeps_ordinary_links() {\n        let md = \"[a](javascript:alert(1)) [b](JaVaScRiPt:x) [c](https://example.test) [d](page.html?q=a:b) ![i](data:text/html;base64,AAAA)\";\n        let out = render(md, Settings { safe: true, ..Settings::default() }).body;\n        assert!(!out.to_lowercase().contains(\"javascript:\"), \"{out}\");\n        assert!(!out.contains(\"data:\"), \"{out}\");\n        assert!(out.contains(\"https://example.test\") &amp;&amp; out.contains(\"page.html?q=a:b\"));\n    }\n\n    #[test]\n    fn markdown_links_are_rewritten_but_external_ones_are_not() {\n        let md = \"[a](guide.md) and [b](https://example.test/x.md)\";\n        let out = render(md, Settings { rewrite_md_links: true, ..Settings::default() }).body;\n        assert!(out.contains(\"href=\\\"guide.html\\\"\"));\n        assert!(out.contains(\"href=\\\"https://example.test/x.md\\\"\"));\n    }\n\n    #[test]\n    fn every_heading_gets_a_unique_enough_id_and_a_toc_entry() {\n        let r = render(\"# One\\n\\n## Two\\n\\n## Two\\n\", Settings::default());\n        assert_eq!(r.toc.len(), 3);\n        assert!(r.body.contains(\"id=\\\"one\\\"\"));\n        assert!(r.body.contains(\"id=\\\"two\\\"\") &amp;&amp; r.body.contains(\"id=\\\"two-1\\\"\"));\n    }\n}</code></pre>\n<p>The full converter is in that listing, and the safe-mode arm is one line near the top of the <code>filter_map</code>: <code>Event::Html(_) | Event::InlineHtml(_) if settings.safe =&gt; None</code>. A test locks it in: with default settings the output contains <code>&lt;script&gt;</code>; with safe mode it contains neither the script nor an <code>onclick</code> attribute on an inline tag, and the surrounding text (<code>hello</code>, <code>world</code>, <code>bold</code>) survives.</p>\n<h3>Dropping HTML is not enough</h3>\n<p>A converter that removes raw HTML is still unsafe, and this is the part that deserves attention. Markdown has its own syntax for links and images, and the URLs inside can carry executable schemes. A plain link and an image, converted in safe mode before the URL fix:</p>\n<pre><code class=\"language-text\">&lt;p&gt;&lt;a href=\"javascript:alert(1)\"&gt;click&lt;/a&gt; and &lt;img src=\"data:text/html;base64,AAAA\" alt=\"img\" /&gt;&lt;/p&gt;</code></pre>\n<p>The script tag is gone and the <code>javascript:</code> URL is not. Clicking the link runs the code. Safe mode therefore has a second job: inspect each link and image destination and replace anything that is not an ordinary web, mail or relative URL.</p>\n<pre data-filename=\"src/convert.rs\"><code class=\"language-rust\">fn is_safe_url(url: &amp;str) -&gt; bool {\n    let trimmed = url.trim_start();\n    match trimmed.split_once(':') {\n        // A colon before any slash or question mark means the text before it is a scheme.\n        Some((scheme, _)) if !scheme.contains(['/', '?', '#']) =&gt; {\n            matches!(scheme.to_ascii_lowercase().as_str(), \"http\" | \"https\" | \"mailto\")\n        }\n        _ =&gt; true,\n    }\n}</code></pre>\n<p>The function is an allow-list, not a block-list: it accepts three schemes by name and refuses everything else, so a scheme nobody has thought of yet is blocked by default. It lowercases the scheme because browsers do (<code>JaVaScRiPt:</code> runs), and it treats a colon after a slash or a question mark as part of the path, so a relative link like <code>page.html?q=a:b</code> survives. After the fix, the same input converts to:</p>\n<pre><code class=\"language-text\">&lt;p&gt;&lt;a href=\"#\"&gt;click&lt;/a&gt; and &lt;img src=\"\" alt=\"img\" /&gt;&lt;/p&gt;\n&lt;p&gt;&lt;a href=\"https://example.test\"&gt;ok&lt;/a&gt; &lt;a href=\"page.html\"&gt;rel&lt;/a&gt;&lt;/p&gt;</code></pre>\n<p>The dangerous link now points at <code>#</code> and the image has an empty source, while the ordinary links are untouched. The test <code>safe_mode_neutralizes_script_urls_but_keeps_ordinary_links</code> asserts both halves, including that the mixed-case <code>JaVaScRiPt:</code> is caught.</p>\n<p>Be honest about what this is. It is a reasonable defense for a small tool, and it is not a full HTML sanitizer. For content from untrusted users that will be shown on a website, convert with this tool and then pass the HTML through a real sanitizer such as the <code>ammonia</code> crate, which parses the output and applies an allow-list of tags and attributes. Layered defenses fail less often than any single filter, and a sanitizer is the layer that does not depend on you remembering every case.</p>\n<h2>Headings, anchors and a table of contents</h2>\n<p>Readers want to link to a section, and the table of contents needs somewhere to point. The converter makes two passes over the events. The first collects headings: when it sees the start of a heading it begins accumulating text, and at the end it computes an id from the text. The <code>slug</code> function lowercases, drops punctuation and joins words with hyphens, so <code>Rust &amp; WebAssembly: a tour</code> becomes <code>rust-webassembly-a-tour</code>.</p>\n<p>A naive slug collides when two headings share a title, and that happens constantly (<code>Setup</code>, <code>Examples</code>, <code>Notes</code>). The first version used the slug directly and a test that claimed to check uniqueness only checked the count of headings, which passed. Looking at the output showed the bug:</p>\n<pre><code class=\"language-text\">id=\"notes\"\nid=\"setup\"\nid=\"setup\"</code></pre>\n<p>Two headings with the same id are invalid HTML, and the second link in the table of contents jumps to the first section. The fix counts occurrences of each slug and appends a suffix to repeats, the convention used by GitHub:</p>\n<pre><code class=\"language-text\">id=\"notes\"\nid=\"setup\"\nid=\"setup-1\"</code></pre>\n<p>The test now asserts both <code>id=\"two\"</code> and <code>id=\"two-1\"</code> for a document with two <code>Two</code> headings. The lesson is about tests as much as slugs: a test that asserts the length of a list proves the list has that many entries and says nothing about the entries. Assert the thing you care about.</p>\n<p>The second pass runs the events through <code>filter_map</code> and rewrites each heading start to carry its precomputed id, in order. Because the first pass and the second walk the same document in the same order, the nth heading in one corresponds to the nth in the other. That coupling is the weak point of the design, and it is acceptable here because both passes use the same options. Changing the options between passes would shift the indices, which is why the options live in one function.</p>\n<h2>Rewriting links between pages</h2>\n<p>Documentation written as a folder of Markdown files links between pages with <code>guide.md</code>. After conversion those links should point at <code>guide.html</code>. The event stream again makes this a local change: match <code>Start(Tag::Link { dest_url, .. })</code>, and if the destination ends in <code>.md</code> and has no <code>://</code> in it, swap the suffix. Links to other sites that happen to end in <code>.md</code> are left alone, because the check for a scheme separator excludes them:</p>\n<pre><code class=\"language-text\">&lt;a href=\"guide.html\"&gt;\n&lt;a href=\"https://example.test/guide.md\"&gt;\n&lt;a href=\"#1\"&gt;</code></pre>\n<p>The third line is the footnote reference, untouched. The rewrite handles fragments poorly (<code>guide.md#setup</code> would not match the suffix check), which is a good first extension: split off the <code>#fragment</code> before testing and reattach it afterward.</p>\n<h2>A full page, and the command line</h2>\n<p>A fragment of HTML is rarely what you want to publish. The <code>page</code> function wraps the body in a document with a charset, a title and a navigation list built from the table of contents, escaping the title and heading text so a heading containing <code>&lt;</code> cannot break the markup. The title comes from the <code>--title</code> flag or, by default, the first heading:</p>\n<pre><code class=\"language-text\">&lt;!doctype html&gt;\n&lt;html lang=\"en\"&gt;\n&lt;head&gt;\n&lt;meta charset=\"utf-8\"&gt;\n&lt;title&gt;Release notes&lt;/title&gt;\n&lt;/head&gt;\n&lt;body&gt;\n&lt;nav&gt;&lt;ul&gt;\n&lt;li class=\"h1\"&gt;&lt;a href=\"#release-notes\"&gt;Release notes&lt;/a&gt;&lt;/li&gt;\n&lt;li class=\"h2\"&gt;&lt;a href=\"#features\"&gt;Features&lt;/a&gt;&lt;/li&gt;\n&lt;/ul&gt;&lt;/nav&gt;\n&lt;main&gt;\n&lt;h1 id=\"release-notes\"&gt;Release notes&lt;/h1&gt;</code></pre>\n<p>The binary is a thin shell, as every tool in this series is: parse arguments with <code>clap</code>, call <code>convert::render</code>, write the result, and map errors to an exit code.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">mod convert;\n\nuse std::io::{self, Read, Write};\nuse std::path::PathBuf;\nuse std::process::ExitCode;\n\nuse anyhow::{Context, Result};\nuse clap::Parser;\n\n/// Convert Markdown to HTML.\n#[derive(Parser)]\n#[command(name = \"mdhtml\", version)]\nstruct Cli {\n    /// Markdown file to read. Reads standard input when omitted.\n    input: Option&lt;PathBuf&gt;,\n    /// Write the result here instead of standard output.\n    #[arg(short, long)]\n    output: Option&lt;PathBuf&gt;,\n    /// Wrap the output in a full HTML page with a table of contents.\n    #[arg(long)]\n    page: bool,\n    /// Remove raw HTML from the input. Use this for text you do not trust.\n    #[arg(long)]\n    safe: bool,\n    /// Point links to `other.md` at `other.html`.\n    #[arg(long)]\n    rewrite_links: bool,\n    /// Page title; defaults to the first heading.\n    #[arg(long)]\n    title: Option&lt;String&gt;,\n}\n\nfn run(cli: &amp;Cli) -&gt; Result&lt;()&gt; {\n    let markdown = match &amp;cli.input {\n        Some(path) =&gt; std::fs::read_to_string(path).with_context(|| format!(\"reading {}\", path.display()))?,\n        None =&gt; {\n            let mut buf = String::new();\n            io::stdin().read_to_string(&amp;mut buf).context(\"reading standard input\")?;\n            buf\n        }\n    };\n    let settings = convert::Settings { safe: cli.safe, rewrite_md_links: cli.rewrite_links };\n    let rendered = convert::render(&amp;markdown, settings);\n    let output = if cli.page {\n        let title = cli.title.clone().or_else(|| rendered.toc.first().map(|(_, t, _)| t.clone())).unwrap_or_else(|| \"Untitled\".into());\n        convert::page(&amp;title, &amp;rendered)\n    } else {\n        rendered.body\n    };\n    match &amp;cli.output {\n        Some(path) =&gt; std::fs::write(path, output).with_context(|| format!(\"writing {}\", path.display()))?,\n        None =&gt; io::stdout().write_all(output.as_bytes()).context(\"writing standard output\")?,\n    }\n    Ok(())\n}\n\nfn main() -&gt; ExitCode {\n    match run(&amp;Cli::parse()) {\n        Ok(()) =&gt; ExitCode::SUCCESS,\n        Err(e) =&gt; {\n            eprintln!(\"mdhtml: {e:#}\");\n            ExitCode::from(2)\n        }\n    }\n}</code></pre>\n<p>Reading from a file, from standard input when none is given, writing to a file or standard output, and the three behavior flags (<code>--page</code>, <code>--safe</code>, <code>--rewrite-links</code>) cover most uses. A missing input file produces a one-line message with the path in it and exit status 2:</p>\n<pre><code class=\"language-text\">mdhtml: reading nope.md: No such file or directory (os error 2)</code></pre>\n<p>Run <code>mdhtml --safe docs/guide.md &gt; guide.html</code> in a shell script, and the pipeline works with <code>curl</code>, <code>grep</code> and <code>xargs</code> like any other filter. For a whole folder, <code>find docs -name '*.md' | xargs -I{} mdhtml --page --safe --rewrite-links {} -o {}.html</code> converts every page.</p>\n<h2>Front matter and code blocks: two more event-stream jobs</h2>\n<p>Static sites put metadata at the top of each page in a block fenced by <code>---</code>, and tools often want to list or lint the code samples in a document. Neither needs a new parser. Front matter is text before Markdown starts, so you split it off first; code blocks are runs of events between <code>Start(Tag::CodeBlock)</code> and <code>End(TagEnd::CodeBlock)</code>, so you collect them from the stream. This program does both on one document and prints what it found:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::collections::BTreeMap;\n\nuse pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};\n\n/// Split a leading `---` block of `key: value` lines from the Markdown that follows it.\nfn split_front_matter(text: &amp;str) -&gt; (BTreeMap&lt;String, String&gt;, &amp;str) {\n    let mut meta = BTreeMap::new();\n    let Some(rest) = text.strip_prefix(\"---\\n\") else { return (meta, text) };\n    let Some(end) = rest.find(\"\\n---\\n\") else { return (meta, text) };\n    for line in rest[..end].lines() {\n        if let Some((key, value)) = line.split_once(':') {\n            meta.insert(key.trim().to_string(), value.trim().trim_matches('\"').to_string());\n        }\n    }\n    (meta, &amp;rest[end + 5..])\n}\n\n/// Every fenced code block with its language tag.\nfn code_blocks(markdown: &amp;str) -&gt; Vec&lt;(String, String)&gt; {\n    let mut out = Vec::new();\n    let mut current: Option&lt;(String, String)&gt; = None;\n    for event in Parser::new_ext(markdown, Options::empty()) {\n        match event {\n            Event::Start(Tag::CodeBlock(kind)) =&gt; {\n                let lang = match kind {\n                    CodeBlockKind::Fenced(info) =&gt; info.split_whitespace().next().unwrap_or(\"\").to_string(),\n                    CodeBlockKind::Indented =&gt; String::new(),\n                };\n                current = Some((lang, String::new()));\n            }\n            Event::Text(t) =&gt; {\n                if let Some((_, code)) = current.as_mut() {\n                    code.push_str(&amp;t);\n                }\n            }\n            Event::End(TagEnd::CodeBlock) =&gt; out.extend(current.take()),\n            _ =&gt; {}\n        }\n    }\n    out\n}\n\nfn main() {\n    let doc = \"---\\ntitle: \\\"Ownership notes\\\"\\ntags: rust, memory\\n---\\n# Intro\\n\\n```rust\\nlet s = String::from(\\\"hi\\\");\\n```\\n\\nText.\\n\\n```toml\\n[dependencies]\\nserde = \\\"1\\\"\\n```\\n\";\n    let (meta, body) = split_front_matter(doc);\n    println!(\"front matter: {meta:?}\");\n    println!(\"body starts: {:?}\", body.lines().next());\n    for (lang, code) in code_blocks(body) {\n        println!(\"{lang:&gt;5}: {} line(s), first = {:?}\", code.lines().count(), code.lines().next().unwrap_or(\"\"));\n    }\n    let (no_meta, same) = split_front_matter(\"# Just a heading\\n\");\n    println!(\"without front matter: {} keys, body unchanged: {}\", no_meta.len(), same == \"# Just a heading\\n\");\n}</code></pre>\n<pre><code class=\"language-text\">front matter: {\"tags\": \"rust, memory\", \"title\": \"Ownership notes\"}\nbody starts: Some(\"# Intro\")\n rust: 1 line(s), first = \"let s = String::from(\\\"hi\\\");\"\n toml: 2 line(s), first = \"[dependencies]\"\nwithout front matter: 0 keys, body unchanged: true</code></pre>\n<p>Several details in <code>split_front_matter</code> are the sort that bite later. It returns the metadata and a slice of the original text, <code>&amp;str</code>, borrowed from the input, so the body is not copied. It requires the opening fence at the very start and a closing fence on its own line, and if either is missing it returns the text unchanged with no metadata, which is the safe choice: a document that starts with a horizontal rule, <code>---</code>, must not lose its first lines. It uses a <code>BTreeMap</code> so the keys print in a stable order, which matters for tests. And it handles only flat <code>key: value</code> lines. Real YAML has lists, nested maps and multi-line strings, and for those the right move is the <code>serde_yaml</code> crate (or TOML front matter with <code>toml</code>), not a longer hand-rolled parser.</p>\n<p><code>code_blocks</code> shows the standard technique for collecting text between two markers: keep an <code>Option</code> accumulator, set it on the start event, append <code>Text</code> events while it is <code>Some</code>, and take it on the end event. The language comes from the fence's info string, <code>rust</code> or <code>toml</code> here, and an indented block has none. A documentation checker uses this to find every <code>rust</code> block and compile it, which is exactly how <code>cargo test</code> checks doctests. The same loop with a different action (write each block to a file, count lines, run a formatter) covers most \"do something with the code samples\" tools.</p>\n<h2>Testing the transformations</h2>\n<p>A converter is a pure function from text to text, which is as testable as code gets, and its failures are specific: a link that did not rewrite, an id that collided, a script tag that survived. Keep one test per behavior and make each assertion about the property, not about the whole output string, because the exact HTML changes when the library updates. For example, assert that the output <code>contains(\"href=\\\"guide.html\\\"\")</code>, not that it equals a full document. Add one snapshot test of a complete sample document so that an upgrade shows up as a reviewable diff; the <code>insta</code> crate manages the stored snapshots. And add every bug you find to the suite as a failing case before fixing it: the duplicate-heading ids and the <code>javascript:</code> URL both entered the project that way.</p>\n<p>Run the whole suite on a corpus of real documents once in a while. Parsers meet input that tests never imagine, such as a file with Windows line endings, a table with a missing cell, or a footnote reference with no definition. The event stream is forgiving, and the output should be too: render something sensible and never panic. A quick check is to run the converter over every Markdown file in a large repository and confirm that it exits zero for all of them.</p>\n<h2>Speed</h2>\n<p>The pass structure is cheap. A generated document of 4,000 sections with tables, lists and links, 0.56 MB, converted with <code>--safe --rewrite-links --page</code> in about 10 milliseconds once the file was in the cache (the first cold run took 0.23 s, dominated by reading the file from disk), producing 1.48 MB of HTML. The pull parser's lack of an in-memory tree is the reason. If you ever need to convert thousands of files, the time is in process startup and disk I/O, not in the conversion, so convert in one process with a loop instead of launching <code>mdhtml</code> per file.</p>\n<h2>Extending it</h2>\n<p>Each of these is a change to the event stream. Syntax highlighting for fenced code blocks: collect the text events inside <code>Start(Tag::CodeBlock(...))</code>, run them through a highlighter such as <code>syntect</code>, and emit one <code>Event::Html</code> with the colored markup. That is the one place where producing raw HTML inside safe mode is correct, because you generated it. Front matter: strip a leading <code>---</code> block before parsing and return it separately. Heading permalinks: add an anchor <code>Html</code> event after each heading's text. Custom containers like <code>:::note</code>: the parser does not support them, so you recognize them in text events yourself, or reach for a parser that does.</p>\n<h2>When to use something else</h2>\n<p>For a documentation site with a theme, search and navigation, <code>mdBook</code> or a static site generator already exists and solves it. If you need strict GitHub-flavored Markdown output and a tree to manipulate, <code>comrak</code> builds an AST and mirrors GitHub's renderer. If you need many output formats, <code>pandoc</code> converts between dozens. Use <code>pulldown-cmark</code> directly when you want a fast, small, embeddable converter that you can bend through an iterator, as in a static site generator you are writing, a preview in an editor, or a build step. For the other half of that toolbox, <a href=\"/library/rust-markdown-link-checker\">building a Markdown link checker</a> parses the same documents to verify their links.</p>\n<h2>Running it in a build</h2>\n<p>A converter earns its place when it runs unattended. In a documentation repository the usual wiring is a build step that converts every page and fails the build if anything goes wrong. Two properties of the tool make that reliable. It exits non-zero with a message on any I/O failure, so a missing or unreadable file stops the job instead of producing a half-built site. And its output is deterministic: the same input and flags give byte-identical HTML, because ids depend only on the heading text and the order of the document, with no clock and no random state. That lets you commit the generated pages and review the diff when a source page changes, or cache the build by hashing the inputs.</p>\n<p>Pair it with the link checker from <a href=\"/library/rust-markdown-link-checker\">the Markdown link checker guide</a>: convert with <code>--rewrite-links</code> so pages point at each other's HTML, then check that every internal link resolves to a file that exists. A pipeline that converts, checks links and sanitizes the result catches most documentation bugs before a reader sees them. Pin the versions of <code>pulldown-cmark</code> in <code>Cargo.lock</code> and update on purpose, since a parser upgrade can legitimately change the HTML for edge cases, and the snapshot test is where you notice.</p>","date_published":"2026-11-03T11:02:00+00:00","tags":["CLI"]},{"id":"https://rusthacks.dev/library/rust-build-jq-clone","url":"https://rusthacks.dev/library/rust-build-jq-clone","title":"Build a jq Style JSON Query Tool in Rust Step by Step","summary":"A jq clone needs three parts: a parser for a small path language, an evaluator over serde_json values and a printer. The core is about 150 lines.","content_html":"<p><code>jq</code> is the tool people reach for when a JSON document is too big to read and too awkward for <code>grep</code>. It is also a small language: a query like <code>.users[] | select(.role == \"dev\") | .name</code> is a program that describes a path through the data, filters it and extracts a field. Building a miniature version in Rust teaches a surprising amount in about 250 lines. You write a tokenizer-free recursive descent parser, model a query as an enum, evaluate it with a function that returns many results, and wrap everything in a command line tool that behaves well in a pipeline.</p>\n<p>The tool built here is called <code>mjq</code>. It supports field access, array indexing with negative indexes, iteration, pipes, <code>length</code>, <code>keys</code>, <code>map</code>, <code>select</code> with the six comparison operators, and a mode for streams of newline-delimited JSON. It was built and tested on Rust 1.99 (edition 2024) with <code>serde_json</code> 1.0.154 and <code>clap</code> 4.6.7, and every output below is real.</p>\n<h2>What the language needs</h2>\n<p>Start from the queries you want to run, because the grammar follows from them.</p>\n<ul><li><code>.team</code> reads the field <code>team</code> of the input object.</li><li><code>.users[0].name</code> goes into an array by index, then reads a field.</li><li><code>.users[-1]</code> picks the last element.</li><li><code>.users[]</code> yields every element of the array, as separate results.</li><li><code>.users | length</code> feeds the array into the <code>length</code> builtin.</li><li><code>.users | map(.role)</code> applies a filter to each element and collects the results.</li><li><code>.users[] | select(.role == \"dev\")</code> keeps only the elements that match.</li></ul>\n<p>Two ideas run through the list. A filter takes one JSON value and produces zero, one or many values; <code>.users[]</code> produces three, <code>select</code> may produce none. And filters compose with <code>|</code>, which feeds every output of the left into the right. Those two facts decide the design: a filter is an enum, and running one returns a <code>Vec&lt;Value&gt;</code>.</p>\n<h2>Model the query as data</h2>\n<p>The parser's output is a tree of <code>Filter</code> values, and the evaluator walks it. Writing the enum first fixes what the language can say:</p>\n<pre data-filename=\"src/query.rs\"><code class=\"language-rust\">#[derive(Debug, Clone, PartialEq)]\npub enum Filter {\n    /// `.` returns the input unchanged.\n    Identity,\n    /// `.name`\n    Field(String),\n    /// `.[3]`\n    Index(i64),\n    /// `.[]` yields every element of an array or every value of an object.\n    Iterate,\n    /// `length`\n    Length,\n    /// `keys`\n    Keys,\n    /// `select(&lt;filter&gt; &lt;op&gt; &lt;json&gt;)` keeps the input when the comparison holds.\n    Select(Box&lt;Filter&gt;, Cmp, Value),\n    /// `map(&lt;filter&gt;)` applies the filter to each element and collects the results.\n    Map(Box&lt;Filter&gt;),\n    /// `a | b` feeds every output of `a` into `b`.\n    Pipe(Box&lt;Filter&gt;, Box&lt;Filter&gt;),\n}</code></pre>\n<p>Notice the <code>Box&lt;Filter&gt;</code> in <code>Select</code>, <code>Map</code> and <code>Pipe</code>: a filter can contain filters, and a type that contains itself needs an indirection to have a finite size. The path <code>.users[0].name</code> does not need its own variant. The parser turns it into <code>Pipe(Field(\"users\"), Pipe(Index(0), Field(\"name\")))</code>, because a path is just a pipeline of steps. That is a common simplification: make the evaluator handle a few primitives and let the parser build composite structure out of them.</p>\n<h2>Evaluate: one input, many outputs</h2>\n<p>The evaluator is one function with a <code>match</code> over the enum. Its type tells the whole story: <code>fn run(filter: &amp;Filter, input: &amp;Value) -&gt; Result&lt;Vec&lt;Value&gt;, String&gt;</code>. The <code>Vec</code> carries the \"zero, one or many\" semantics, and the <code>Result</code> carries failures such as indexing a string.</p>\n<pre data-filename=\"src/query.rs\"><code class=\"language-rust\">Filter::Select(inner, op, wanted) =&gt; {\n    let found = run(inner, input)?;\n    Ok(if found.iter().any(|v| op.holds(v, wanted)) { vec![input.clone()] } else { vec![] })\n}\nFilter::Map(inner) =&gt; {\n    let items = match input {\n        Value::Array(items) =&gt; items,\n        other =&gt; return Err(format!(\"cannot map over {}\", kind(other))),\n    };\n    let mut out = Vec::new();\n    for item in items {\n        out.extend(run(inner, item)?);\n    }\n    Ok(vec![Value::Array(out)])\n}\nFilter::Pipe(left, right) =&gt; {\n    let mut out = Vec::new();\n    for mid in run(left, input)? {\n        out.extend(run(right, &amp;mid)?);\n    }\n    Ok(out)\n}</code></pre>\n<p><code>Pipe</code> is the heart. It runs the left filter, which may produce many values, and for each one runs the right filter, concatenating all the results. That single rule gives you everything jq users find magical: <code>.users[] | .name</code> first expands the array into three values, then takes <code>.name</code> of each, producing three names. <code>Select</code> returns either the input in a one-element vector or an empty vector, which is how filtering falls out of the same shape. <code>Map</code> is <code>Iterate</code> plus a collect, wrapped in an array. Nothing in the evaluator knows about jq specifically; it is three recursion rules and a handful of primitives.</p>\n<p>Errors are values. Indexing a string returns <code>Err(\"cannot index string\")</code>, and the first <code>?</code> stops the whole run. That is stricter than jq, which can continue past some errors, and it is the right default for a small tool: a query that does not match the data should say so.</p>\n<h2>Parse by recursive descent</h2>\n<p>The parser reads bytes with a position and builds the tree. A query is a pipeline of paths separated by <code>|</code>, and a path is a chain of steps:</p>\n<pre data-filename=\"src/query.rs\"><code class=\"language-rust\">fn pipeline(&amp;mut self) -&gt; Result&lt;Filter, ParseError&gt; {\n    let mut left = self.path()?;\n    while self.eat(b'|') {\n        let right = self.path()?;\n        left = Filter::Pipe(Box::new(left), Box::new(right));\n    }\n    Ok(left)\n}</code></pre>\n<p><code>path</code> handles <code>.name</code>, <code>[3]</code>, <code>[-1]</code> and <code>[]</code> in a loop, or dispatches to <code>builtin</code> when the text starts with a letter (<code>length</code>, <code>keys</code>, <code>map(...)</code>, <code>select(...)</code>). <code>map</code> calls <code>pipeline</code> recursively for its argument, which is why <code>map(.a | .b)</code> works. The helper <code>eat(byte)</code> skips whitespace and consumes a byte if it is next, which keeps the code free of manual index arithmetic. The parser never panics on bad input; each failure becomes a <code>ParseError</code> variant with enough context to write a good message.</p>\n<h2>The command line</h2>\n<p>The binary is thin, as every well-structured Rust tool is. <code>clap</code> supplies the arguments, <code>run</code> does the work and returns a <code>Result</code>, and <code>main</code> turns the result into an exit code:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">mod query;\n\nuse std::io::{self, Read};\nuse std::path::PathBuf;\nuse std::process::ExitCode;\n\nuse anyhow::{Context, Result};\nuse clap::Parser;\n\n/// A small jq-style JSON query tool.\n#[derive(Parser)]\n#[command(name = \"mjq\", version)]\nstruct Cli {\n    /// The query, for example `.users[].name`.\n    query: String,\n    /// JSON file to read. Reads standard input when omitted.\n    file: Option&lt;PathBuf&gt;,\n    /// Print raw strings without quotes.\n    #[arg(short, long)]\n    raw: bool,\n    /// Print compact one-line JSON.\n    #[arg(short, long)]\n    compact: bool,\n    /// Treat the input as a stream of JSON values, one per line, and run the query on each.\n    #[arg(short, long)]\n    lines: bool,\n}\n\nfn run(cli: &amp;Cli) -&gt; Result&lt;()&gt; {\n    let text = match &amp;cli.file {\n        Some(path) =&gt; std::fs::read_to_string(path).with_context(|| format!(\"reading {}\", path.display()))?,\n        None =&gt; {\n            let mut buf = String::new();\n            io::stdin().read_to_string(&amp;mut buf).context(\"reading standard input\")?;\n            buf\n        }\n    };\n    let filter = query::parse(&amp;cli.query).context(\"invalid query\")?;\n    if cli.lines {\n        for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {\n            let input: serde_json::Value = serde_json::from_str(line).with_context(|| format!(\"line {} is not valid JSON\", n + 1))?;\n            emit(cli, &amp;filter, &amp;input)?;\n        }\n    } else {\n        let input: serde_json::Value = serde_json::from_str(&amp;text).context(\"the input is not valid JSON\")?;\n        emit(cli, &amp;filter, &amp;input)?;\n    }\n    Ok(())\n}\n\nfn emit(cli: &amp;Cli, filter: &amp;query::Filter, input: &amp;serde_json::Value) -&gt; Result&lt;()&gt; {\n    for value in query::run(filter, input).map_err(anyhow::Error::msg)? {\n        match (&amp;value, cli.raw, cli.compact) {\n            (serde_json::Value::String(s), true, _) =&gt; println!(\"{s}\"),\n            (_, _, true) =&gt; println!(\"{value}\"),\n            _ =&gt; println!(\"{}\", serde_json::to_string_pretty(&amp;value)?),\n        }\n    }\n    Ok(())\n}\n\nfn main() -&gt; ExitCode {\n    match run(&amp;Cli::parse()) {\n        Ok(()) =&gt; ExitCode::SUCCESS,\n        Err(e) =&gt; {\n            eprintln!(\"mjq: {e:#}\");\n            ExitCode::from(2)\n        }\n    }\n}</code></pre>\n<p>It reads the JSON from a file or standard input, parses the query, evaluates it and prints each result on its own line. Flags handle output and input: pretty printing is the default, <code>-c</code> prints compact one-line JSON, <code>-r</code> prints strings without quotes so shell scripts can use them, and <code>-l</code> treats the input as one JSON value per line. Errors go to standard error with exit status 2, matching the convention in <a href=\"/library/rust-cli-clap-to-release\">a command line tool from clap to a shipped release</a>. The <code>with_context</code> calls are what make failures readable: \"reading nope.json\" tells you which step failed and the underlying operating system message says why.</p>\n<h2>Running it</h2>\n<p>Given a small document with a team name and three users:</p>\n<pre data-filename=\"users.json\"><code class=\"language-json\">{\n  \"team\": \"platform\",\n  \"users\": [\n    {\"name\": \"ada\", \"role\": \"admin\", \"langs\": [\"rust\", \"c\"], \"active\": true},\n    {\"name\": \"grace\", \"role\": \"dev\", \"langs\": [\"rust\", \"go\"], \"active\": true},\n    {\"name\": \"linus\", \"role\": \"dev\", \"langs\": [\"c\"], \"active\": false}\n  ]\n}</code></pre>\n<p>These are the queries and their output, with <code>-c</code> for compact results:</p>\n<pre><code class=\"language-text\">mjq '.team'                                      \"platform\"\nmjq '.users[0].name'                             \"ada\"\nmjq '.users[].name'                              \"ada\" \"grace\" \"linus\"\nmjq '.users | length'                            3\nmjq '.users[1] | keys'                           [\"active\",\"langs\",\"name\",\"role\"]\nmjq '.users | map(.role)'                        [\"admin\",\"dev\",\"dev\"]\nmjq '.users[] | select(.role == \"dev\") | .name'  \"grace\" \"linus\"\nmjq '.users[-1].langs[0]'                        \"c\"\nmjq '.missing'                                   null\nmjq '.users[0].name.first'                       mjq: cannot read field \"first\" of string</code></pre>\n<p>Each line is worth reading. A missing field gives <code>null</code>, as in jq, and a field of a string is an error. The <code>keys</code> output is sorted alphabetically because <code>serde_json</code> keeps object keys in sorted order by default; enabling its <code>preserve_order</code> feature would keep the file's order. The <code>select</code> query chains three stages: expand the array, keep the developers, take the names.</p>\n<p>The failure paths matter as much as the happy ones:</p>\n<pre><code class=\"language-text\">mjq '.users['       mjq: invalid query: the query ended too early\nmjq 'users'         mjq: invalid query: unknown function \"users\" (try length, keys, map, select)\n$ echo 'not json' | mjq '.a'\nmjq: the input is not valid JSON: expected ident at line 1 column 2</code></pre>\n<p>The second message was a bug in an earlier draft: the unknown function <code>users</code> was reported as \"not valid JSON\" because it reused the literal error variant. Writing the test that exercised every error case (<code>errors_are_values_not_panics</code> below) is what found it. Give each failure its own variant, so each gets its own message.</p>\n<h2>Comparison operators</h2>\n<p>The first version of <code>select</code> supported only <code>==</code>. Real queries need ordering: \"people over 30\", \"names after h\". Adding it touches three places and teaches how a small language grows. A <code>Cmp</code> enum names the six operators. The parser gets a <code>comparison</code> method that reads <code>==</code>, <code>!=</code>, <code>&lt;=</code>, <code>&gt;=</code>, <code>&lt;</code> or <code>&gt;</code>, checking two-character operators before one-character ones so <code>&gt;=</code> is not read as <code>&gt;</code> followed by garbage. And <code>Cmp::holds</code> decides what each operator means for each pair of JSON types:</p>\n<pre data-filename=\"src/query.rs\"><code class=\"language-rust\">impl Cmp {\n    fn holds(self, left: &amp;Value, right: &amp;Value) -&gt; bool {\n        use std::cmp::Ordering;\n        let ordering = match (left, right) {\n            (Value::Number(a), Value::Number(b)) =&gt; a.as_f64().partial_cmp(&amp;b.as_f64()),\n            (Value::String(a), Value::String(b)) =&gt; Some(a.cmp(b)),\n            _ =&gt; None,\n        };\n        match self {\n            Cmp::Eq =&gt; left == right,\n            Cmp::Ne =&gt; left != right,\n            // Ordering is defined only between two numbers or two strings.\n            Cmp::Lt =&gt; ordering == Some(Ordering::Less),\n            Cmp::Le =&gt; matches!(ordering, Some(Ordering::Less | Ordering::Equal)),\n            Cmp::Gt =&gt; ordering == Some(Ordering::Greater),\n            Cmp::Ge =&gt; matches!(ordering, Some(Ordering::Greater | Ordering::Equal)),\n        }\n    }\n}</code></pre>\n<p>The design choice is in the <code>_ =&gt; None</code> arm: ordering a number against a string, or an object against anything, has no answer, so <code>&lt;</code> and <code>&gt;</code> are simply false, while <code>==</code> and <code>!=</code> work on any pair. Real jq defines a total order across types; for a small tool, \"false when not comparable\" is easier to explain. <code>partial_cmp</code> on <code>f64</code> returns <code>None</code> for NaN, which the same code handles without a special case, since a <code>None</code> ordering makes every ordered comparison false. The test that locks this behavior in was written to the wrong expectation first:</p>\n<pre><code class=\"language-text\">assertion `left == right` failed\n  left: [Number(36)]\n right: [Number(41), Number(36)]</code></pre>\n<p>The test asked for ages of people whose names are at least <code>\"h\"</code>, and expected two. The program returned one, and the program was right: <code>\"grace\"</code> starts with <code>g</code>, which sorts before <code>h</code>, so only <code>\"linus\"</code> qualifies. The expectation was the bug. Failing tests are evidence, not verdicts; read both sides before editing either. With the expectation corrected, five tests pass, and the new queries work:</p>\n<pre><code class=\"language-text\">mjq -c '.people[] | select(.age &gt; 30) | .name'   \"grace\" \"linus\"</code></pre>\n<h2>Streams of values: newline-delimited JSON</h2>\n<p>Logs and event feeds are often JSON values one per line, not one big array. Reading such a file as a single document fails on the second line. The <code>-l</code> flag processes each line separately, running the same filter and printing results as they arrive, which also keeps memory bounded by the size of one line. The structure is a loop over <code>text.lines()</code>, a <code>filter</code> that skips blank lines, and the same <code>emit</code> function that handled the single-document case, extracted so both paths share it. Applied to a four-line event log:</p>\n<pre><code class=\"language-text\">mjq -c -l 'select(.level == \"error\") | .ms' events.ndjson   340 95</code></pre>\n<p>The two error events had latencies of 340 and 95 milliseconds, and the two info events were filtered out. A malformed line reports its number and stops:</p>\n<pre><code class=\"language-text\">$ printf '{\"a\":1}\\noops\\n' | mjq -c -l '.'\n{\"a\":1}\nmjq: line 2 is not valid JSON: expected value at line 1 column 1</code></pre>\n<p>The first line's output appeared before the failure, which is the behavior you want from a streaming tool: results for good lines are not held hostage by a bad one later. The exit status was 2. For truly unbounded streams, replace <code>read_to_string</code> with a <code>BufReader</code> and <code>lines()</code> so the file is never fully in memory. That is the version to ship if you point this at a log that grows forever.</p>\n<h2>How fast is it?</h2>\n<p>A tool like this should be measured against the real thing. The test inputs were 300,000 generated user records: a 25.7 MB single document and the same records as 25.4 MB of newline-delimited JSON. The release build of <code>mjq</code> ran on an Apple silicon laptop, and <code>jq</code> is the version installed by the system package manager. Times are wall-clock seconds from <code>time</code>.</p>\n<p>All times are the best of five runs, in wall-clock seconds, for the release build:</p>\n<ul><li><code>.team</code> (parse the document, read one field): mjq 0.20 s.</li><li><code>.users | length</code>: mjq 0.34 s, jq 1.6 0.51 s.</li><li><code>.users[] | select(.role == \"ops\") | .name</code>, 59,603 matches: mjq 0.57 s, jq 1.6 0.62 s.</li><li><code>-l 'select(.age &gt; 60) | .id'</code> over the newline-delimited file, 56,657 matches: mjq 0.26 s, one run.</li></ul>\n<p>Three observations come out of it. Parsing dominates: just reading the document and returning one field takes 0.20 s, so more than half of the <code>length</code> query's 0.34 s is <code>serde_json</code> building the tree. The remaining 0.14 s is the cost of a design shortcut named earlier: <code>Field</code> clones the whole array so the next stage can own it. On the filtering query, <code>mjq</code> already matches <code>jq</code> 1.6, which is a respectable result for 300 lines of code and no tuning, since both spend most of their time in the parse. And the streaming mode is fast because each line is a small allocation that is dropped before the next, which is friendly to the cache.</p>\n<h2>Removing the clones with <code>Cow</code></h2>\n<p>The shortcut is easy to remove, and removing it is a good lesson in how Rust expresses \"borrow when you can, own when you must\". The evaluator returns <code>Vec&lt;Value&gt;</code>, so every result is owned. Most filters do not need to own anything: <code>Field</code>, <code>Index</code>, <code>Iterate</code> and <code>Identity</code> return parts of the input, and <code>Select</code> returns the input itself. Only <code>Length</code>, <code>Keys</code> and <code>Map</code> build new values. The type that models \"either a borrow or an owned value\" is <code>Cow</code>:</p>\n<pre data-filename=\"src/query.rs\"><code class=\"language-rust\">pub fn run&lt;'a&gt;(filter: &amp;Filter, input: &amp;'a Value) -&gt; Result&lt;Vec&lt;Cow&lt;'a, Value&gt;&gt;, String&gt; {\n    match filter {\n        Filter::Identity =&gt; Ok(vec![Cow::Borrowed(input)]),\n        Filter::Field(name) =&gt; match input {\n            Value::Object(map) =&gt; Ok(vec![Cow::Borrowed(map.get(name).unwrap_or(&amp;NULL))]),\n            Value::Null =&gt; Ok(vec![Cow::Borrowed(&amp;NULL)]),\n            other =&gt; Err(format!(\"cannot read field {name:?} of {}\", kind(other))),\n        },\n        Filter::Iterate =&gt; match input {\n            Value::Array(items) =&gt; Ok(items.iter().map(Cow::Borrowed).collect()),\n            Value::Object(map) =&gt; Ok(map.values().map(Cow::Borrowed).collect()),\n            other =&gt; Err(format!(\"cannot iterate over {}\", kind(other))),\n        },\n        Filter::Length =&gt; Ok(vec![Cow::Owned(match input {\n            Value::Array(a) =&gt; a.len().into(),\n            Value::String(s) =&gt; s.chars().count().into(),\n            _ =&gt; 0.into(),\n        })]),\n        // ... Index, Keys, Select and Map follow the same pattern\n        Filter::Pipe(left, right) =&gt; {\n            let mut out = Vec::new();\n            for mid in run(left, input)? {\n                match mid {\n                    Cow::Borrowed(r) =&gt; out.extend(run(right, r)?),\n                    Cow::Owned(v) =&gt; out.extend(run(right, &amp;v)?.into_iter().map(|c| Cow::Owned(c.into_owned()))),\n                }\n            }\n            Ok(out)\n        }\n    }\n}</code></pre>\n<p>Two details carry the design. The lifetime <code>'a</code> on the input and on the <code>Cow</code> says that borrowed results live as long as the input document, which the compiler checks. A missing field returns a borrow of a <code>static NULL</code>, so even \"not found\" allocates nothing. And <code>Pipe</code> shows the cost of mixing: if the left stage returned a borrow, the right stage keeps borrowing from the same input; if it returned an owned value, the right stage's results might point into that value, which dies at the end of the arm, so they are copied out with <code>into_owned</code>. The compiler insists on that copy; without it the borrow would outlive its owner, the <code>E0597</code> family of errors from <a href=\"/library/e0597-does-not-live-long-enough\">E0597</a>. Five tests passed unchanged apart from converting results with <code>into_owned</code> in the helper.</p>\n<p>The measured effect on the 25.7 MB file, best of five:</p>\n<ul><li><code>.users | length</code>: 0.34 s before, 0.21 s after, which is the parse cost alone.</li><li><code>.users[] | select(.role == \"ops\") | .name</code>: 0.57 s before, 0.29 s after, about twice as fast as <code>jq</code> 1.6 (0.62 s).</li></ul>\n<p>Removing the clones took the length query to the floor set by parsing and cut the filter query nearly in half. The code change was one function. The lesson is the order of work: write the simple, cloning evaluator first, get the tests green, measure, and only then thread lifetimes through, with the tests as your safety net.</p>\n<h2>Tests</h2>\n<p>The evaluator and parser are pure functions, which makes them cheap to test without a binary:</p>\n<pre data-filename=\"src/query.rs\"><code class=\"language-rust\">#[test]\nfn iteration_yields_many_outputs() {\n    let v = json!([1, 2, 3]);\n    assert_eq!(q(\".[]\", &amp;v), [json!(1), json!(2), json!(3)]);\n}\n\n#[test]\nfn select_filters_and_map_collects() {\n    let v = json!([{\"k\": 1}, {\"k\": 2}, {\"k\": 1}]);\n    assert_eq!(q(\".[] | select(.k == 1)\", &amp;v).len(), 2);\n    assert_eq!(q(\"map(.k)\", &amp;v), [json!([1, 2, 1])]);\n}</code></pre>\n<pre><code class=\"language-text\">test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s</code></pre>\n<p>Four tests cover paths and negative indexes, iteration, <code>select</code> and <code>map</code>, and the error cases. The <code>json!</code> macro builds <code>Value</code>s inline, which keeps the test data next to the assertions. Add a test every time you find a bug by hand, and keep the case small.</p>\n<h2>What this version leaves out, and the costs</h2>\n<p>It clones aggressively. Each stage copies its inputs into <code>Vec&lt;Value&gt;</code>, which is fine for documents of a few megabytes and wasteful for a 2 GB file. A faster design returns references (<code>Vec&lt;&amp;Value&gt;</code>) for read-only filters and clones only when a filter must construct a new value, using <code>Cow&lt;Value&gt;</code> to keep both cases in one type. It parses the whole document into memory, where a streaming parser would handle files larger than RAM; the <code>serde_json</code> streaming deserializer reads a sequence of values from a reader, which is how line-delimited JSON is processed. And its language is small: no arithmetic, no <code>if</code>, no variables, no <code>//</code> alternatives, no <code>..</code> recursive descent, no string interpolation. Each of those is a new <code>Filter</code> variant and a few lines in <code>run</code>, which is a pleasant extension path: add <code>+</code> and <code>-</code>, then comparison operators beyond <code>==</code>, then <code>if</code>, and you have the core of jq.</p>\n<p>Where the real <code>jq</code> is the better tool, use it. It has hundreds of builtins, handles streaming and has been tested against every odd corner of JSON. Build your own when you want a query language embedded in your program, a deliberately small and safe subset for users to write in a config file, or the education. The same recursive-descent structure powers the calculator in <a href=\"/library/rust-project-ideas\">ten Rust project ideas</a>, and the <code>Result</code>-returning style is the one explained in <a href=\"/library/error-handling-in-rust\">error handling in Rust</a>.</p>\n<h2>Where to take it next</h2>\n<p>Pick extensions by the Rust concept they teach. A <code>--sort-keys</code> flag teaches <code>BTreeMap</code> versus <code>IndexMap</code> and the <code>preserve_order</code> feature. A <code>sort_by(.field)</code> builtin teaches closures, <code>sort_by</code> and stable ordering of JSON values. An <code>--arg name=value</code> option with <code>$name</code> variables teaches environments threaded through the evaluator, which means passing a context struct alongside <code>input</code>. A real tokenizer in front of the parser teaches how to report error positions precisely. And a <code>--stream</code> mode built on a <code>BufReader</code> and <code>serde_json::Deserializer::into_iter</code> teaches iterator-based pipelines that never hold more than one value. Each is an afternoon of work, each ships with its own tests, and together they turn the toy into something you would use. The next project in the same family is a Markdown converter, which swaps JSON for a text format and the filter tree for an event stream.</p>","date_published":"2026-11-03T11:00:00+00:00","tags":["CLI"]},{"id":"https://rusthacks.dev/library/rust-graceful-shutdown-kubernetes","url":"https://rusthacks.dev/library/rust-graceful-shutdown-kubernetes","title":"Graceful Shutdown on Kubernetes for Rust Services Explained","summary":"When Kubernetes stops a pod, traffic keeps arriving for a moment after SIGTERM. A correct shutdown fails readiness, waits briefly, then drains requests before exiting.","content_html":"<p>A rolling update is a controlled outage of every pod, one at a time. If the old pod exits carelessly, a few requests fail every deploy. The sequence Kubernetes runs on shutdown is well defined, and a Rust service can follow it in about forty lines. Versions: Rust 1.90, Axum 0.8, Tokio 1.47, Kubernetes 1.31.</p>\n<h2>What Kubernetes does</h2>\n<p>When a pod is deleted:</p>\n<ol><li>The pod is marked <code>Terminating</code>.</li><li>In parallel, the endpoints controller removes the pod from Service endpoints, and the <code>preStop</code> hook (if any) runs.</li><li>After the hook, the container receives <code>SIGTERM</code>.</li><li>After <code>terminationGracePeriodSeconds</code>, it receives <code>SIGKILL</code>.</li></ol>\n<p>Step 2's removal takes time to reach kube-proxy, ingress controllers and external load balancers. Requests can arrive for several seconds after <code>SIGTERM</code>. An app that closes its listener immediately resets those connections.</p>\n<h2>The shutdown sequence in the app</h2>\n<ol><li>On <code>SIGTERM</code>, set a flag so <code>/ready</code> returns 503.</li><li>Wait a short delay for endpoint removal to propagate (3 to 10 seconds).</li><li>Stop accepting new connections and wait for in-flight requests to finish, with a timeout.</li><li>Exit 0.</li></ol>\n<h2>The code</h2>\n<pre><code class=\"language-rust\">use axum::{extract::State, http::StatusCode, routing::get, Router};\nuse std::sync::{\n    atomic::{AtomicBool, Ordering},\n    Arc,\n};\nuse std::time::Duration;\nuse tokio::signal::unix::{signal, SignalKind};\n\n#[derive(Clone)]\nstruct AppState {\n    draining: Arc&lt;AtomicBool&gt;,\n}\n\nasync fn ready(State(s): State&lt;AppState&gt;) -&gt; StatusCode {\n    if s.draining.load(Ordering::SeqCst) {\n        StatusCode::SERVICE_UNAVAILABLE\n    } else {\n        StatusCode::OK\n    }\n}\n\nasync fn work() -&gt; &amp;'static str {\n    tokio::time::sleep(Duration::from_millis(200)).await;\n    \"done\"\n}\n\n#[tokio::main]\nasync fn main() {\n    let state = AppState { draining: Arc::new(AtomicBool::new(false)) };\n    let app = Router::new()\n        .route(\"/work\", get(work))\n        .route(\"/ready\", get(ready))\n        .route(\"/live\", get(|| async { \"ok\" }))\n        .with_state(state.clone());\n\n    let listener = tokio::net::TcpListener::bind(\"0.0.0.0:8080\").await.unwrap();\n\n    let shutdown = async move {\n        let mut term = signal(SignalKind::terminate()).unwrap();\n        let mut int = signal(SignalKind::interrupt()).unwrap();\n        tokio::select! {\n            _ = term.recv() =&gt; {}\n            _ = int.recv() =&gt; {}\n        }\n        tracing::info!(\"shutdown signal received, failing readiness\");\n        state.draining.store(true, Ordering::SeqCst);\n        tokio::time::sleep(Duration::from_secs(5)).await;\n        tracing::info!(\"draining connections\");\n    };\n\n    axum::serve(listener, app)\n        .with_graceful_shutdown(shutdown)\n        .await\n        .unwrap();\n    tracing::info!(\"clean exit\");\n}</code></pre>\n<p>After the <code>shutdown</code> future completes, Axum stops accepting new connections and waits for existing ones to finish. Keep-alive connections that are idle close, and connections with a request in flight complete first. If a handler might run for minutes, wrap the whole <code>serve</code> in a <code>timeout</code> so a stuck request cannot hold shutdown forever (<a href=\"/library/axum-graceful-shutdown\">Axum graceful shutdown</a>, <a href=\"/library/tokio-graceful-shutdown\">Tokio graceful shutdown</a>).</p>\n<h2>The manifest</h2>\n<pre><code class=\"language-yaml\">apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: api\nspec:\n  replicas: 3\n  strategy:\n    rollingUpdate:\n      maxUnavailable: 0\n      maxSurge: 1\n  template:\n    spec:\n      terminationGracePeriodSeconds: 45\n      containers:\n        - name: api\n          image: registry.example.test/api:1.4.0\n          ports: [{ containerPort: 8080 }]\n          readinessProbe:\n            httpGet: { path: /ready, port: 8080 }\n            periodSeconds: 2\n            failureThreshold: 1\n          livenessProbe:\n            httpGet: { path: /live, port: 8080 }\n            periodSeconds: 10</code></pre>\n<p>Choose the grace period larger than: the readiness delay (5 s) plus your longest request plus a margin. <code>periodSeconds: 2</code> and <code>failureThreshold: 1</code> make the readiness failure take effect fast. The liveness probe must keep passing during drain, or Kubernetes will kill the pod early, which is why <code>/live</code> is separate (<a href=\"/library/rust-health-readiness-liveness\">health checks</a>).</p>\n<h2>Alternative: preStop</h2>\n<p>If you do not want sleep logic in the app:</p>\n<pre><code class=\"language-yaml\">lifecycle:\n  preStop:\n    exec:\n      command: [\"sleep\", \"8\"]</code></pre>\n<p>This needs a <code>sleep</code> binary in the image, absent in distroless and scratch images. Newer Kubernetes versions offer a <code>sleep</code> action natively: <code>preStop: { sleep: { seconds: 8 } }</code>. The grace period includes the hook's time.</p>\n<h2>Testing the behavior</h2>\n<pre><code class=\"language-bash\">kubectl rollout restart deployment/api &amp;\noha -z 60s -c 20 https://api.example.test/work</code></pre>\n<p>Count non 2xx responses during the rollout. Zero is the target. Failures that are connection resets point to a too-short delay, and 503s from the app point to readiness flipping before the load balancer stops sending. Add long-lived connections and WebSockets to the test, since they need a policy to close them with a close frame (<a href=\"/library/rust-zero-downtime-deploys\">zero downtime deploys</a>).</p>\n<h2>Pitfalls</h2>\n<ul><li>PID 1 handling: run the binary directly, not through a shell script that swallows the signal. Use <code>exec</code> or <code>tini</code>.</li><li>Background tasks must stop too: cancel them with a token (<a href=\"/library/tokio-cancel-tasks\">cancelling tasks</a>).</li><li>Jobs with long work should checkpoint, since the grace period is finite.</li><li>Log the phases with timestamps so you can see where time went.</li></ul>","date_published":"2026-11-03T10:44:00+00:00","tags":["DevOps"]},{"id":"https://rusthacks.dev/library/rust-ebpf-aya","url":"https://rusthacks.dev/library/rust-ebpf-aya","title":"eBPF and Rust With Aya for Observability Tools Step by Step","summary":"Aya lets you write both the eBPF program and its loader in Rust. A small tracepoint program counts syscalls and a user space process reads the counters from a map.","content_html":"<p>Observing a running Linux system usually means changing it (adding logging, restarting under a debugger) or accepting the cost of tools that slow it down. eBPF turns that around. You write a very small program, the kernel proves it safe, and it runs inside the kernel at a hook point such as \"a process called exec\", reporting what it sees into a shared table that an ordinary program reads. Aya lets you write both halves in Rust: the kernel program and the user-space loader. This guide builds the smallest useful example, a counter of how many times each program name was executed, and it was actually loaded into a Linux kernel and run. It also shows the most important thing about eBPF, the verifier, by deliberately writing a program the kernel refuses and reading its refusal. Versions: aya 0.16.0, aya-ebpf 0.3.0, bpf-linker 0.13.0, rustc 1.99.0 for user space and nightly 1.101.0 for the kernel program, on Linux 7.0.14 (the kernel Docker Desktop runs in its virtual machine) on an Apple M1 Max.</p>\n<h2>What eBPF is, in terms of what you write</h2>\n<p>An eBPF program is a function compiled to a restricted instruction set and loaded into the kernel with the <code>bpf()</code> system call. Before it runs, the kernel's <em>verifier</em> walks every possible path through it and rejects it unless it can prove the program terminates, never reads memory it should not, and never dereferences a pointer it has not checked. Once accepted, it is compiled to native code and attached to a <em>hook</em>: a tracepoint, a function entry, a network interface. Whenever the hook fires, the program runs. Programs share data with user space through <em>maps</em>, kernel-resident tables (hash maps, arrays, ring buffers) that both sides can read and write.</p>\n<p>That gives a three-part shape to every Aya project. A <strong>kernel crate</strong> compiled for the <code>bpfel-unknown-none</code> target holds the programs and declares the maps. A <strong>user-space crate</strong> loads the compiled object, attaches the programs to hooks, and reads the maps. Optionally a <strong>shared crate</strong> holds types both sides use. The restricted nature of the kernel side is why it is <code>#![no_std]</code> and <code>#![no_main]</code>: there is no allocator, no standard library, no threads, only the helper functions the kernel provides.</p>\n<h2>Setting up the toolchain</h2>\n<p>The kernel crate needs two things most Rust projects do not. It needs the nightly compiler, because the eBPF target has no prebuilt standard library and must be built from source with <code>-Z build-std=core</code>, which is a nightly flag, plus the <code>rust-src</code> component. And it needs <code>bpf-linker</code>, a linker that turns Rust's LLVM output into a loadable eBPF object. Both went into one Docker image, because Linux is the only place this runs and Docker Desktop's virtual machine provides a Linux kernel on a Mac:</p>\n<pre data-filename=\"Dockerfile\"><code class=\"language-dockerfile\">FROM rust:1.99-slim-bookworm\nRUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends build-essential pkg-config git curl ca-certificates zstd bpftool iproute2 \\\n &amp;&amp; rm -rf /var/lib/apt/lists/*\nRUN rustup toolchain install nightly --profile minimal --component rust-src\nRUN curl -sL https://github.com/aya-rs/bpf-linker/releases/latest/download/bpf-linker-aarch64-unknown-linux-musl.tar.zst -o /tmp/bl.tar.zst \\\n &amp;&amp; tar -xpf /tmp/bl.tar.zst -C /usr/local/bin &amp;&amp; rm /tmp/bl.tar.zst</code></pre>\n<p>Two notes on that file. The linker is installed from the project's release tarball and not with <code>cargo install bpf-linker</code>: the project's own README advises against building it from source, because a source build is tied to one specific LLVM version, and the nightly compiler used here carries LLVM 23.1.5. The prebuilt binary reported <code>bpf-linker 0.13.0</code> and worked on the first try. And the tarball name contains the CPU architecture (<code>aarch64</code> here); on an x86-64 machine you would fetch the <code>x86_64</code> one.</p>\n<h2>The kernel program</h2>\n<p>The kernel crate is small. It declares one map, a hash from a 16-byte process name to a 64-bit counter, and one program attached to a tracepoint:</p>\n<pre data-filename=\"ebpf/src/main.rs\"><code class=\"language-rust\">#![no_std]\n#![no_main]\n\nuse aya_ebpf::{\n    helpers::bpf_get_current_comm,\n    macros::{map, tracepoint},\n    maps::HashMap,\n    programs::TracePointContext,\n};\n\n#[map]\nstatic EXEC_COUNTS: HashMap&lt;[u8; 16], u64&gt; = HashMap::with_max_entries(1024, 0);\n\n#[tracepoint]\npub fn count_exec(_ctx: TracePointContext) -&gt; u32 {\n    let comm = bpf_get_current_comm().unwrap_or([0u8; 16]);\n    unsafe {\n        match EXEC_COUNTS.get_ptr_mut(&amp;comm) {\n            Some(count) =&gt; *count += 1,\n            None => {\n                let _ = EXEC_COUNTS.insert(&amp;comm, &amp;1u64, 0);\n            }\n        }\n    }\n    0\n}\n\n#[panic_handler]\nfn panic(_info: &amp;core::panic::PanicInfo) -&gt; ! {\n    unsafe { core::hint::unreachable_unchecked() }\n}</code></pre>\n<p>Read it as the kernel will. <code>#[map]</code> places the static into an ELF section the loader recognises as a map definition, with room for 1,024 distinct names. <code>#[tracepoint]</code> marks <code>count_exec</code> as a program of type tracepoint; its argument is the context the kernel passes (unused here, so the underscore). The body asks the kernel for the name of the current process with <code>bpf_get_current_comm</code>, a 16-byte buffer, then looks it up in the map. If the name is present, it increments the counter; if not, it inserts a 1. The lookup returns a raw pointer, which is why that block is <code>unsafe</code>, and the <code>match</code> on <code>Some</code> and <code>None</code> is not just style: the kernel's verifier insists on exactly that null check, as the failure test below proves. The panic handler must exist because there is no standard library; it tells the compiler that reaching it is impossible, which is acceptable because the verifier would have rejected any path that could panic.</p>\n<p>The manifest sets the profile for size and speed, with debug information on for the loader:</p>\n<pre data-filename=\"ebpf/Cargo.toml\"><code class=\"language-toml\">[package]\nname = \"lab-ebpf\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\naya-ebpf = \"0.3\"\n\n[[bin]]\nname = \"lab-ebpf\"\npath = \"src/main.rs\"\n\n[profile.release]\nopt-level = 3\nlto = true\npanic = \"abort\"\ncodegen-units = 1\ndebug = 2</code></pre>\n<p>It is built for the eBPF target with the nightly flag described above:</p>\n<pre><code class=\"language-bash\">cd ebpf\ncargo +nightly build --target=bpfel-unknown-none -Z build-std=core --release</code></pre>\n<p>The build finished in 13.6 seconds, most of it compiling <code>core</code> and the dependencies. The output is not a native executable. It is an ELF object of 2,192 bytes, and reading its section table shows what the loader will find:</p>\n<pre><code class=\"language-bash\">readelf -S -W target/bpfel-unknown-none/release/lab-ebpf | grep -E \"tracepoint|maps\"</code></pre>\n<pre><code class=\"language-text\">  [ 3] tracepoint        PROGBITS        0000000000000000 0001a0 000300 00  AX  0   0  8\n  [ 4] .reltracepoint    REL             0000000000000000 0005a0 000050 10   I  7   3  8\n  [ 5] maps              PROGBITS        0000000000000000 0004a0 00001c 00  WA  0   0  4</code></pre>\n<p>The program lives in a section named <code>tracepoint</code>, 0x300 (768) bytes of eBPF instructions. The <code>maps</code> section is 0x1c (28) bytes, the definition of the one map. The <code>.reltracepoint</code> section holds relocations: places in the program that must be patched with the map's real file descriptor at load time. This is what \"the loader\" does, and why a compiled eBPF program is a file you load and not a binary you execute.</p>\n<h2>The user-space half</h2>\n<p>The loader is an ordinary Rust program using the <code>aya</code> crate. It embeds the object file into its own binary at compile time, loads the program into the kernel, attaches it to the <code>sched_process_exec</code> tracepoint, does some work to observe, and then reads the map:</p>\n<pre data-filename=\"app/src/main.rs\"><code class=\"language-rust\">use anyhow::{Context, Result};\nuse aya::{Ebpf, maps::HashMap, programs::TracePoint};\nuse std::process::{Command, Stdio};\n\nfn main() -&gt; Result&lt;()&gt; {\n    let mut ebpf = Ebpf::load(aya::include_bytes_aligned!(concat!(\n        env!(\"CARGO_MANIFEST_DIR\"),\n        \"/../ebpf/target/bpfel-unknown-none/release/lab-ebpf\"\n    )))\n    .context(\"loading the eBPF object\")?;\n\n    let program: &amp;mut TracePoint = ebpf.program_mut(\"count_exec\").context(\"no such program\")?.try_into()?;\n    program.load().context(\"the kernel verifier rejected the program\")?;\n    program.attach(\"sched\", \"sched_process_exec\")?;\n    println!(\"attached count_exec to sched:sched_process_exec\");\n\n    // Generate some work to observe: three commands, a few times each.\n    for (cmd, times) in [(\"true\", 5), (\"ls\", 3), (\"uname\", 2)] {\n        for _ in 0..times {\n            Command::new(cmd).stdout(Stdio::null()).status()?;\n        }\n    }\n\n    let counts: HashMap&lt;_, [u8; 16], u64&gt; = HashMap::try_from(ebpf.map(\"EXEC_COUNTS\").context(\"no map\")?)?;\n    let mut rows: Vec&lt;(String, u64)&gt; = counts\n        .iter()\n        .map(|kv| kv.map(|(k, v)| (String::from_utf8_lossy(&amp;k).trim_end_matches('\\0').to_string(), v)))\n        .collect::&lt;Result&lt;_, _&gt;&gt;()?;\n    rows.sort();\n    for (comm, n) in rows {\n        println!(\"{comm:&gt;8}  {n}\");\n    }\n    Ok(())\n}</code></pre>\n<p>The steps map one to one onto the concepts above. <code>Ebpf::load</code> parses the object. <code>program_mut(\"count_exec\")</code> finds the program by its function name, and <code>load()</code> is the point at which the kernel's verifier runs. <code>attach(\"sched\", \"sched_process_exec\")</code> connects it to the tracepoint named by category and event. The loader then launches known commands (five <code>true</code>, three <code>ls</code>, two <code>uname</code>) so the answer is predictable, reads the typed map and prints it. The key type is the same 16-byte array on both sides, and the typed <code>HashMap&lt;_, [u8; 16], u64&gt;</code> wrapper checks that at the boundary.</p>\n<h2>Running it</h2>\n<p>Loading eBPF needs privileges, and attaching a tracepoint needs the kernel's tracing file system mounted. In a container, that means <code>--privileged</code> and mounting <code>tracefs</code>. Here is the run:</p>\n<pre><code class=\"language-bash\">docker run --rm --privileged -v \"$PWD:/w\" -w /w/app aya-lab bash -c \\\n  'mount -t tracefs nodev /sys/kernel/tracing; ./target/release/lab'</code></pre>\n<pre><code class=\"language-text\">attached count_exec to sched:sched_process_exec\n      ls  3\n    true  5\n   uname  2</code></pre>\n<p>The counts are exactly what the loader ran: three <code>ls</code>, five <code>true</code>, two <code>uname</code>. They are the kernel program's counts, not the loader's. Every <code>exec</code> in that window passed through <code>count_exec</code> and landed in the map. The total ran inside the kernel with no changes to <code>ls</code> and no tracing of the loader itself. (The loader's own start-up is not in the table because it started before the attach.)</p>\n<h2>Looking at the loaded program from outside</h2>\n<p>Once a program is in the kernel, it belongs to the system, not to your loader, and the standard tool for inspecting it is <code>bpftool</code>. I changed the loader to wait a few seconds before reading the map (an environment variable, <code>HOLD_SECS</code>) and ran <code>bpftool</code> from the same container while it was waiting:</p>\n<pre><code class=\"language-bash\">bpftool prog show | grep -A2 count_exec\nbpftool map show | grep -A1 EXEC_COUNTS\nbpftool prog dump xlated id \"$PID\"</code></pre>\n<pre><code class=\"language-text\">1597: tracepoint  name count_exec  tag 7cd66e66b72eecde  gpl\n\tloaded_at 2026-11-07T19:30:42+0000  uid 0\n\txlated 848B  jited 600B  memlock 4096B  map_ids 106,105\n106: hash  name EXEC_COUNTS  flags 0x0\n\tkey 16B  value 8B  max_entries 1024  memlock 91744B\n   0: (bf) r6 = r10\n   1: (07) r6 += -16\n   2: (bf) r1 = r6\n   3: (b7) r2 = 0\n   4: (b7) r3 = 16\n   5: (85) call pc+92#bpf_prog_4f9302ba65b90ccd_F</code></pre>\n<p>The first line is the kernel's record of your program: id 1597, type <code>tracepoint</code>, the name you gave the function, a hash of its instructions (<code>tag</code>) and the licence string <code>gpl</code>. That licence matters: the kernel only lets a program call certain helper functions if it declares a GPL-compatible licence, and Aya sets it for you. The size line is a useful number. The program the verifier accepted is <strong>848 bytes</strong> of translated eBPF instructions, and the kernel compiled it to <strong>600 bytes</strong> of native arm64 code (<code>jited</code>). The map is a hash with 16-byte keys, 8-byte values and room for 1,024 entries, and the kernel charged 91,744 bytes of locked memory for it. Those 91,744 bytes are the cost of reserving space for the full table, not for the three entries in use, which is why <code>max_entries</code> deserves a thought.</p>\n<p>The dump of the translated program starts the way the verifier trace did: the stack pointer copy, the call that zeroes the name buffer (a call to a subprogram, <code>call pc+92</code>), and then <code>bpf_get_current_comm</code>. It is the same code in a more compact form. Being able to read it is mostly useful for one purpose: when the verifier rejects something, the instruction numbers in its message refer to exactly this listing, so you can find the line that failed.</p>\n<h2>It watches the whole machine</h2>\n<p>The run with the wait was also instructive for what it counted. The first run, with no pause, saw only my three commands. This one printed the same three plus a number of other names:</p>\n<pre><code class=\"language-text\">attached count_exec to sched:sched_process_exec\n       6  16\n bpftool  4\n     cut  1\n    grep  3\n    head  3\n      ls  3\nmysqladmin  8\nredis-cli  7\n    runc  16\n      sh  1\n    true  5\n   uname  2\n    wget  1</code></pre>\n<p>The additional names are the shell commands I used to inspect (<code>bpftool</code>, <code>grep</code>, <code>head</code>, <code>cut</code>, <code>sh</code>) and, more tellingly, programs from other containers on the same virtual machine: <code>mysqladmin</code>, <code>redis-cli</code>, <code>runc</code> and <code>wget</code>, which are what health checks of unrelated containers execute. Even <code>true</code> and <code>ls</code> appear because the loader ran them. (The row whose name is just <code>6</code> is a process whose name is the digit 6; I did not identify it, and the row shows that names are whatever the kernel stored, not a vetted label.) That is the point and the hazard of this kind of tool in one table: a tracepoint is global. It sees every <code>exec</code> on the kernel, in every container and namespace, not just yours, which is why it needs privilege and why anything built on it should be treated as a security-sensitive component. It is also why filtering by cgroup or PID namespace inside the kernel program is a standard early improvement.</p>\n<h2>What the verifier refuses</h2>\n<p>The verifier is the part of eBPF that Rust developers meet first, because it is a second type checker that you cannot argue with. To see it, I removed the null check and wrote what looks like reasonable code: look up the counter and increment it.</p>\n<pre data-filename=\"ebpf/src/main.rs (the broken version)\"><code class=\"language-rust\">unsafe {\n    // Missing the null check: the lookup can fail.\n    *EXEC_COUNTS.get_ptr_mut(&amp;comm).unwrap_unchecked() += 1;\n}</code></pre>\n<p>It compiles. Rust has no objection, because the <code>unsafe</code> block says to trust the programmer. The kernel does not. Loading it printed a long verifier trace, instruction by instruction with the state of each register, and at the end:</p>\n<pre><code class=\"language-text\">80: (85) call bpf_map_lookup_elem#1   ; R0=map_value_or_null(id=4,map=EXEC_COUNTS,ks=16,vs=8)\n81: (79) r1 = *(u64 *)(r0 +0)\nR0 invalid mem access 'map_value_or_null'\nverification time 155 usec\nstack depth 48+0\nprocessed 159 insns (limit 1000000)</code></pre>\n<p>Read the last lines like a compiler error. Instruction 80 called the map lookup helper, and the verifier tracked its return value as <code>map_value_or_null</code>: a pointer that might be null. Instruction 81 then read through it, and the verifier stopped: \"R0 invalid mem access\". The register R0 holds the helper's result, and the verifier will not allow a dereference until a null comparison has narrowed its type. The trace before this point (about 160 instructions in all) also contains an unrelated detail worth seeing once: the compiler unrolled the zeroing of the 16-byte name buffer into a small loop, and the verifier walked it iteration by iteration, which is how it proves loops terminate. Programs that loop over data of unknown size are the ones that get rejected for exceeding the verifier's limits, which is why eBPF code favours bounded loops and fixed-size buffers.</p>\n<p>The error text is long and the fix is short: restore the <code>match</code>. The lesson is that the kernel enforces a property Rust's types do not express here, and the error arrives at load time, not at compile time. So test loading in CI, not just compiling.</p>\n<h2>A mistake in the build loop</h2>\n<p>When I introduced the broken program I rebuilt only the kernel crate and ran the loader, and it happily printed the correct counts. The loader had embedded the <em>old</em> object when it was compiled: <code>include_bytes_aligned!</code> copies the file into the user-space binary at build time, so changing the kernel crate does nothing until the loader is rebuilt. After <code>touch app/src/main.rs</code> and a rebuild the verifier error appeared. If a change to your kernel program seems to have no effect, this is the first thing to check, and a build script (or the <code>aya-build</code> crate) that rebuilds the kernel crate and triggers the loader is the usual cure. The same trap runs in the other direction: the loader reported a correct result from a program I had already broken.</p>\n<h2>Reading the result safely</h2>\n<p>Three details of the map access matter in real tools. First, map reads from user space are not atomic with respect to the kernel program, so a counter can be read while it is being incremented; for monotonic counts that is fine, for pairs of values that must agree it is not. Second, per-CPU variants of the map types (<code>PerCpuHashMap</code>) avoid contention by giving each CPU its own slot, at the cost of the loader summing them. Third, the map has a fixed capacity, 1,024 here; when it is full, an insert fails, and the kernel program above silently ignores that failure with <code>let _ =</code>. A real tool would count the failures in a second map so lost events are visible.</p>\n<h2>Where this stops</h2>\n<ul>\n<li><strong>This ran in a virtual machine, not on bare metal.</strong> Docker Desktop on a Mac runs containers inside a Linux VM with kernel 7.0.14. That kernel had BPF, JIT and BTF enabled, and the tracepoint worked, but a production Linux host differs in kernel version, configuration, security modules and lockdown settings.</li>\n<li><strong>Only one hook type was used.</strong> Tracepoints are the stable, easiest hook. Kprobes, uprobes, XDP, tc and cgroup hooks have different contexts, different helper sets and different stability promises. None was run here.</li>\n<li><strong>aya-log and ring buffers were not used.</strong> Printing from the kernel and streaming events to user space are the next things most tools need, and they are the more complex part. A hash map polled from user space is the simplest pattern, not the most scalable.</li>\n<li><strong>Privilege is the real cost.</strong> Loading eBPF needs CAP_BPF and CAP_PERFMON (or root) and, in the container, a privileged run. That is a large grant; scope it carefully and never expose a tool like this to untrusted users.</li>\n<li><strong>The arm64 toolchain only.</strong> The linker tarball, the object and the numbers come from aarch64. The source is portable, but I did not build for x86-64.</li>\n<li><strong>Cleanup is the loader's job.</strong> A program stays loaded only while something holds a reference to it. When the loader exits, Aya detaches the tracepoint and the kernel frees the program and the map; a tool that pins them to the BPF file system instead keeps them alive after exit, and then someone has to remove them. I only ran the first kind.</li>\n<li><strong>The counting was a toy.</strong> It counts by process name, so two different programs with the same name merge, and a program that renames itself escapes. A real tool keys on PID and executable path.</li>\n</ul>\n<p>The measured outcome is short. A 2,192-byte object built in 13.6 seconds was accepted by a real kernel, attached to <code>sched_process_exec</code>, and counted ten process launches exactly (three, five and two). Removing one null check produced a rejection at instruction 81 with a precise reason. Aya made both halves ordinary Rust, and the verifier made one of them stricter than Rust ever will be.</p>","date_published":"2026-11-03T10:40:00+00:00","tags":["DevOps"]},{"id":"https://rusthacks.dev/library/rust-neovim-lsp","url":"https://rusthacks.dev/library/rust-neovim-lsp","title":"Editing Rust in Neovim With LSP and Treesitter Setup","summary":"Neovim talks to rust-analyzer through its built-in LSP client. A short config gives completion, diagnostics, formatting and Treesitter highlighting, and rustaceanvim adds Rust extras.","content_html":"<p>Neovim ships with a language server client, and since version 0.11 it also ships the configuration API and the default key mappings that make a plugin-free Rust setup practical. rust-analyzer provides the intelligence: type-aware hover, go to definition, references, rename, diagnostics from the compiler and from clippy, and formatting through rustfmt. This guide sets up that stack with about twenty lines of Lua and no plugins at all, then drives it in a headless Neovim, a Neovim with no screen that runs a script, to show what each feature returns. Everything below was run, and the output is real. Versions: Neovim 0.12.6, rust-analyzer 1.99.0, rustc 1.99.0, on an Apple M1 Max with macOS. Completion menus and the visual look of diagnostics need a screen, so those parts are described from the documented behaviour and are marked.</p>\n<h2>What the pieces do</h2>\n<p>Three programs cooperate, and knowing which one answers which question saves time when something breaks. <strong>Neovim</strong> is the editor and the LSP client: it starts a server, sends it the buffer's text and your requests, and shows the replies. <strong>rust-analyzer</strong> is the language server: it parses and type-checks your crate itself, quickly and incrementally, to answer hover and navigation requests. <strong>cargo check</strong> (or <code>cargo clippy</code>) is run by rust-analyzer in the background on save to produce the compiler's own diagnostics. So the editor never talks to rustc; the server does. That is why two sources of errors appear on screen, labelled <code>rust-analyzer</code> and <code>clippy</code> in the output further down: the first is instant and approximate, the second is slower and exact.</p>\n<p>Install rust-analyzer with rustup, so it matches the toolchain, and confirm Neovim's version, because the configuration API used here does not exist before 0.11:</p>\n<pre><code class=\"language-bash\">rustup component add rust-analyzer\nrust-analyzer --version\nnvim --version | head -1</code></pre>\n<pre><code class=\"language-text\">rust-analyzer 1.99.0 (b940084d 2026-09-28)\nNVIM v0.12.6</code></pre>\n<p>Using the rustup component rather than a separate download has one useful property: <code>rust-analyzer</code> reports the same version string as the compiler it was built with, 1.99.0 here, so there is no mismatch between the analyser's idea of the standard library and the compiler's. A distribution package or a download from the project can lag behind a new compiler.</p>\n<h2>The whole configuration</h2>\n<p>Put this in <code>~/.config/nvim/init.lua</code> (or, to try it first, in any file passed with <code>nvim -u</code>). It defines the server once and turns it on. The first call describes how to start and attach; the second activates it for every Rust buffer:</p>\n<pre data-filename=\"init.lua\"><code class=\"language-lua\">vim.lsp.config('rust_analyzer', {\n  cmd = { 'rust-analyzer' },\n  filetypes = { 'rust' },\n  root_markers = { 'Cargo.toml', '.git' },\n  settings = { ['rust-analyzer'] = { check = { command = 'clippy' } } },\n})\nvim.lsp.enable('rust_analyzer')</code></pre>\n<p>Each field has one job. <code>cmd</code> is the program to run. <code>filetypes</code> limits attachment to Rust buffers. <code>root_markers</code> is how Neovim decides what the project is: it walks up from the file until it finds a <code>Cargo.toml</code>, and that directory becomes the root passed to the server, which means a workspace with several crates should have its own <code>Cargo.toml</code> at the top. <code>settings</code> forwards options to rust-analyzer; here, <code>check.command = 'clippy'</code> makes the background check run clippy instead of plain <code>cargo check</code>, so lint warnings show up as diagnostics. There is no plugin manager and no <code>lspconfig</code>: in 0.11 and later, the configuration API is part of Neovim, and plugins like <code>nvim-lspconfig</code> mostly supply ready-made defaults for other languages.</p>\n<h2>Does it attach? Ask Neovim</h2>\n<p>A headless run is the way to test an editor setup without looking at it, and a script is a way to prove the configuration rather than assume it. I pointed Neovim at a small crate (the signed-integer parser from the VS Code guide, a library plus a binary) and ran a Lua script with <code>nvim --headless -u init.lua -c \"luafile test.lua\"</code>. The script opens <code>src/main.rs</code>, waits for a client, and prints what it found:</p>\n<pre data-filename=\"test.lua (first part)\"><code class=\"language-lua\">local function log(...) io.stdout:write(table.concat(vim.tbl_map(tostring, { ... }), ' '), '\\n') end\nvim.cmd('edit src/main.rs')\nlocal ok = vim.wait(60000, function() return #vim.lsp.get_clients({ bufnr = 0 }) > 0 end, 200)\nlocal c = vim.lsp.get_clients({ bufnr = 0 })[1]\nlog('attached:', ok, c and c.name, c and c.root_dir and vim.fs.basename(c.root_dir))\nlog('server version:', c.server_info and c.server_info.version)\nlog('capabilities: hover', c.server_capabilities.hoverProvider ~= nil,\n    'rename', c.server_capabilities.renameProvider ~= nil,\n    'inlayHint', c.server_capabilities.inlayHintProvider ~= nil)</code></pre>\n<pre><code class=\"language-text\">attached: true rust_analyzer nv\nserver version: 1.99.0 (b940084d 2026-09-28)\ncapabilities: hover true rename true inlayHint true</code></pre>\n<p>The client attached, named <code>rust_analyzer</code>, with the root directory <code>nv</code>, the folder holding <code>Cargo.toml</code>. The server identifies as 1.99.0. The capabilities line is the server telling the client what it can do; any feature listed as <code>false</code> would have no mapping to call. If <code>attached</code> is <code>false</code> in your own run, the usual causes are, in order: the file is not detected as Rust (<code>:set filetype?</code>), no <code>Cargo.toml</code> above the file so no root is found, or <code>rust-analyzer</code> is not on the <code>PATH</code> Neovim sees. The command <code>:checkhealth vim.lsp</code> reports the active clients and flags a missing executable.</p>\n<h2>The keys you get for free</h2>\n<p>From 0.11 on, Neovim defines default mappings for LSP actions when a server attaches, so a fresh configuration is already usable. I asked Neovim what each key is bound to in a Rust buffer after attach:</p>\n<pre><code class=\"language-text\">K      vim.lsp.buf.hover()\ngrn    vim.lsp.buf.rename()\ngra    vim.lsp.buf.code_action()\ngrr    vim.lsp.buf.references()\ngri    vim.lsp.buf.implementation()\ngrt    vim.lsp.buf.type_definition()\ngO     vim.lsp.buf.document_symbol()\n&lt;C-S&gt;  vim.lsp.buf.signature_help()</code></pre>\n<p>Notice what is missing from that list: go to definition. In recent Neovim it is not a <code>gd</code> mapping but the editor's tag stack: for a buffer with an attached server, <code>Ctrl-]</code> jumps to the definition and <code>Ctrl-t</code> returns, using the server through the <code>tagfunc</code> option. I did not press those keys in the headless run; I called the underlying request instead, as the next section shows. Most people add <code>gd</code> as a habit, and it is one line in a callback.</p>\n<h2>What each request returns</h2>\n<p>The script places the cursor on the call to <code>parse_signed</code> in <code>main.rs</code> (line 7, column 21) and sends the same protocol requests the keys send. rust-analyzer answers <code>null</code> until it has indexed the project, so the helper retries until it gets a real answer; in an interactive session you notice this as hover that does nothing for the first seconds in a big workspace. These are the results.</p>\n<p><strong>Hover</strong> returned the crate, the full signature and the doc comment, as Markdown:</p>\n<pre><code class=\"language-text\">```rust\napp\n```\n```rust\npub fn parse_signed(input: &amp;str) -&gt; Result&lt;i64, String&gt;\n```\n---\nParses a signed decimal integer, allowing a leading + or -.</code></pre>\n<p>That is the documentation comment from <code>lib.rs</code>, pulled across files by the server's index. In the editor it appears in a floating window when you press <code>K</code>. It is also a quick way to see an inferred type: hover over a <code>let</code> binding and you get the type the compiler assigned.</p>\n<p><strong>Definition</strong> returned <code>lib.rs line 1</code>, the function in the other file of the crate. <strong>References</strong> returned <code>4</code>: the definition, the call in <code>main.rs</code>, and the two uses in the unit tests inside <code>lib.rs</code>. The count includes the declaration because the request asks for it (<code>includeDeclaration = true</code>); <code>grr</code> shows the list in the quickfix window, and the quickfix list is the way to review a rename before you make it.</p>\n<p><strong>Rename</strong> is the request that shows why a language server beats a search-and-replace. Asked to rename <code>parse_signed</code> to <code>parse_number</code>, the server returned a workspace edit touching two files:</p>\n<pre><code class=\"language-text\">rename parse_signed -&gt; parse_number edits: lib.rs x3, main.rs x1</code></pre>\n<p>Three edits in <code>lib.rs</code> (the definition and the two test calls) and one in <code>main.rs</code>, four in total, each at a real use of the function. Text search would also have rewritten a comment, a string and any other function that happens to contain the name; the server rewrites only the symbol. The edits are returned and not applied by the request itself, so in the editor <code>grn</code> asks for the new name and then applies them across all open and unopened files.</p>\n<h2>Diagnostics: two sources, two speeds</h2>\n<p>To see diagnostics, the script writes a bad line into the buffer, saves, and waits until the client reports something. First a type error, <code>let unused_value: i32 = \"text\";</code>:</p>\n<pre><code class=\"language-text\">diagnostic: line 5 rust-analyzer expected i32, found &amp;'static str</code></pre>\n<p>rust-analyzer's own type checker found it as soon as the text changed, without running cargo. That is the fast, approximate source. A second run replaced the error with code that compiles but that clippy dislikes, <code>let v = vec![1]; let _n = v.len() == 0;</code>. This time the diagnostics came from the background <code>cargo clippy</code> run triggered by the save, a couple of seconds later:</p>\n<pre><code class=\"language-text\">diagnostic: line 5 clippy length comparison to zero  ...  `#[warn(clippy::len_zero)]` on by default\ndiagnostic: line 5 clippy using `is_empty` is clearer and more explicit: `v.is_empty()`</code></pre>\n<p>(The first message is shortened where marked; the full text includes a link to the lint's documentation.) Two things matter here. The <code>source</code> field says which tool spoke, which is how you tell an approximation from the compiler. And clippy's output is the reason for the setting at the top: with the default <code>check.command = \"check\"</code>, the second case would have produced no diagnostic at all. A type error also hides clippy's findings, since clippy does not run on code that does not compile; fix the errors first and the lints appear.</p>\n<h2>Formatting through the server</h2>\n<p>rust-analyzer formats by calling rustfmt, and Neovim exposes that as <code>vim.lsp.buf.format()</code>. I replaced the buffer with deliberately cramped code and formatted it:</p>\n<pre><code class=\"language-lua\">vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'fn main(){let x=1;println!(\"{x}\");}' })\nvim.lsp.buf.format({ async = false, timeout_ms = 20000 })</code></pre>\n<pre><code class=\"language-text\">fn main() {\n    let x = 1;\n    println!(\"{x}\");\n}</code></pre>\n<p>That is rustfmt's output, produced through the language server, using the same <code>rustfmt.toml</code> the command line would use. Because it goes through the protocol, the same call formats other languages with their servers, which is the point of having one mapping.</p>\n<h2>A useful init.lua</h2>\n<p>The defaults are enough to start. A few additions make daily use smoother. This is the full file I would keep: it adds format on save, a <code>gd</code> mapping, inlay hints and a diagnostic style, all through <code>LspAttach</code>, which fires once for each buffer that gets a client. I ran the first block of this file in the headless tests above; the extra mappings and the visual settings below are standard Neovim API that I did not exercise in a headless session, so check them in your editor:</p>\n<pre data-filename=\"init.lua (extended)\"><code class=\"language-lua\">vim.lsp.config('rust_analyzer', {\n  cmd = { 'rust-analyzer' },\n  filetypes = { 'rust' },\n  root_markers = { 'Cargo.toml', '.git' },\n  settings = {\n    ['rust-analyzer'] = {\n      check = { command = 'clippy' },\n      cargo = { allFeatures = true },\n    },\n  },\n})\nvim.lsp.enable('rust_analyzer')\n\nvim.diagnostic.config({ virtual_text = true, severity_sort = true })\n\nvim.api.nvim_create_autocmd('LspAttach', {\n  callback = function(args)\n    local client = vim.lsp.get_client_by_id(args.data.client_id)\n    local opts = { buffer = args.buf }\n    vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)\n    vim.keymap.set('n', '&lt;leader&gt;f', function() vim.lsp.buf.format({ async = true }) end, opts)\n    if client and client:supports_method('textDocument/inlayHint') then\n      vim.lsp.inlay_hint.enable(true, { bufnr = args.buf })\n    end\n    if client and client:supports_method('textDocument/completion') then\n      vim.lsp.completion.enable(true, client.id, args.buf, { autotrigger = true })\n    end\n  end,\n})\n\nvim.api.nvim_create_autocmd('BufWritePre', {\n  pattern = '*.rs',\n  callback = function() vim.lsp.buf.format({ async = false }) end,\n})</code></pre>\n<p>Walk through the additions. <code>cargo.allFeatures = true</code> makes the analyser (and the check) build with every feature enabled, which stops code under <code>#[cfg(feature = \"x\")]</code> from being greyed out, at the cost of checking features you may not ship. <code>vim.diagnostic.config</code> puts the message at the end of the line and sorts errors above warnings. In the <code>LspAttach</code> callback, <code>client:supports_method</code> guards each feature, so the same file works with servers that lack them. Inlay hints draw inferred types and parameter names as faint text, and are turned on per buffer. Completion uses the built-in client: with <code>autotrigger</code> the menu opens as you type. The <code>BufWritePre</code> autocommand runs the server's formatter before each save of a Rust file, so formatting is never a separate step. When I queried inlay hints for the first twenty lines of <code>main.rs</code> in the headless run, the server advertised support but the response was an empty list, and I did not investigate why; I cannot show you hint output from this guide.</p>\n<h2>Performance and the first minute</h2>\n<p>The first request after opening a project is slow, and everything after is fast, because rust-analyzer must load the project's metadata, index the standard library and your dependencies, and then run a first <code>cargo clippy</code>. The helper in my script waited with retries for that reason, and your editor does the same when hover returns nothing in the first seconds. On large workspaces three settings help. Set the check command to <code>check</code> rather than <code>clippy</code> if the lint run is slow, and run clippy from a terminal or CI instead. Keep the target directory used by the analyser separate from your terminal builds with <code>rust-analyzer.cargo.targetDir = true</code>, so the two do not invalidate each other's cache and block each other on the cargo lock. And exclude generated directories with <code>files.excludeDirs</code> if the index feels heavy. I did not benchmark these on a large project; they are the commonly suggested knobs, and your own measurements decide.</p>\n<h2>When it does not work: ask Neovim</h2>\n<p>Most failures in an LSP setup are one of three things, and the editor can tell you which. The health check lists every active client, the command it was started with, the root directory it chose and the settings it was sent, which is the whole configuration as the server received it, not as you remember writing it. This is the report from the headless session, trimmed to the parts that matter:</p>\n<pre><code class=\"language-text\">vim.lsp:\n- LSP log level : WARN\n- Log path: /Users/petehouston/.local/state/nvim/lsp.log\nvim.lsp: Active Clients ~\n- rust_analyzer (id: 1)\n  - Version: 1.99.0 (b940084d 2026-09-28)\n  - Root directory: .../scratchpad/nv\n  - Command: { \"rust-analyzer\" }\n  - Settings: { [\"rust-analyzer\"] = { check = { command = \"clippy\" } } }\n  - Attached buffers: 1\nvim.lsp: Enabled Configurations ~\n- rust_analyzer:\n  - filetypes: rust\n  - root_markers: { \"Cargo.toml\", \".git\" }</code></pre>\n<p>Read it as a checklist. If <strong>Active Clients</strong> is empty, the server never started or never attached: check that the file's filetype matches <code>filetypes</code>, that a root marker exists above the file and that the command is on the <code>PATH</code> Neovim inherited (a GUI launcher often has a shorter <code>PATH</code> than your shell). If the client is listed but features are missing, compare <strong>Settings</strong> with what you intended; a typo in a nested key is silently ignored by the server. If the <strong>Root directory</strong> is not the folder you expected, the analyser is indexing the wrong project, which shows up as \"cannot find module\" errors on code that compiles: in a workspace with several <code>Cargo.toml</code> files, the nearest one wins unless a root marker above it matches first. The log file named in the report holds the protocol traffic at higher levels. Turn it up with <code>vim.lsp.log.set_level('debug')</code> for one session, reproduce the problem, read the file, and turn it back down, because debug logging records every document change and grows quickly.</p>\n<p>Two habits keep the setup healthy over time. Update rust-analyzer with the toolchain (<code>rustup update</code>), since the component is versioned with the compiler and a mismatched analyser is the most common cause of wrong \"cannot find\" errors after a compiler upgrade. And keep the editor configuration in version control next to your other dotfiles; the entire Rust setup here is under thirty lines of Lua, so a clean machine reaches a working editor in the time it takes to install the component and copy one file.</p>\n<h2>Workspaces and multiple crates</h2>\n<p>A repository with several crates under one <code>[workspace]</code> is where the root choice matters most. If the top-level <code>Cargo.toml</code> lists the members, open any file and the analyser should load the whole workspace, so go to definition and rename work across crates. Neovim picks the root by the markers in order, taking the first directory upward that contains any of them. With <code>Cargo.toml</code> listed first and each member crate having its own, the nearest member wins, and the analyser then sees only that crate and its dependencies, not its siblings. For rust-analyzer this is usually harmless, because it reads the workspace from the manifest and discovers the other members itself, but if rename misses a sibling crate, the root is the first thing to check in the health report. The reliable fix is to put the workspace root marker first by testing for <code>[workspace]</code> in a function passed as <code>root_dir</code>, and the lazy fix is to start Neovim from the workspace root.</p>\n<h2>Where this setup stops</h2>\n<ul>\n<li><strong>Everything visual was not seen.</strong> Floating windows, virtual text, the completion menu and inlay hints need a screen. The tests prove the server returns the right data to the client, not how it looks.</li>\n<li><strong>Debugging is separate.</strong> The language server does not run your program. Stepping through code in Neovim needs the debug adapter protocol and a plugin such as <code>nvim-dap</code>, which this guide does not cover.</li>\n<li><strong>Tests and run commands.</strong> VS Code shows a \"Run\" lens above each test, which comes from rust-analyzer's code lens support. Neovim lets you list the available actions with <code>gra</code> but has no lens by default; running a single test is usually a terminal command or a plugin.</li>\n<li><strong>It depends on the Neovim version.</strong> The configuration API and the default keys in this guide need 0.11 or later and were run on 0.12.6. On 0.10 you configure the client through <code>vim.lsp.start</code> or a plugin.</li>\n<li><strong>Macros and build scripts cost time.</strong> A project with heavy procedural macros or large build scripts makes the first load noticeably slower, and a proc-macro crash can disable highlighting for that crate until reload.</li>\n<li><strong>One platform.</strong> Everything ran on macOS, using the rustup rust-analyzer. A Linux package or a different install path changes the <code>cmd</code> and nothing else.</li>\n</ul>\n<p>The measured result is a plugin-free setup that attached on the first try, returned a hover with the doc comment, found four references, planned a four-edit rename across two files, reported a type error and two clippy findings, and formatted through rustfmt. The editing experience comes from a few lines of configuration and a server that already knows your crate.</p>","date_published":"2026-11-03T10:20:00+00:00","tags":["Tooling"]},{"id":"https://rusthacks.dev/library/rust-dev-containers","url":"https://rusthacks.dev/library/rust-dev-containers","title":"Rust Dev Containers for VS Code and Reproducible Setup","summary":"A dev container puts the toolchain, system libraries and services in a Docker image that the editor attaches to. A new contributor opens the folder and everything works.","content_html":"<p>A dev container is a development environment described in files and run in a container: the compiler, the system libraries, the debugger and the editor extensions all come from the repository, so a new contributor, a CI job and your laptop in a year all start from the same place. For Rust the payoff is concrete. The toolchain version stops drifting, native dependencies such as OpenSSL headers are installed once in a Dockerfile rather than in a wiki page, and \"works on my machine\" turns into a question about one image. This guide builds a Rust dev container for a small crate, runs the toolchain inside it, and reports what happened, including a permissions failure on a fresh cache volume that cost a build and the fix. Versions: Docker 29.9.2, the official <code>rust:1.99-slim-bookworm</code> image (rustc 1.99.1), on an Apple M1 Max with Docker Desktop. <strong>One limit up front:</strong> VS Code and the Dev Containers extension are not available on this machine, so I did not open the folder in an editor. Everything the editor would do with <code>devcontainer.json</code> (build the image, mount the volumes, run the commands) I ran by hand with the Docker CLI, and the guide marks the one place that depends on the editor.</p>\n<h2>What goes in the repository</h2>\n<p>The specification is a folder called <code>.devcontainer</code> with two files. The <code>Dockerfile</code> builds the image, and <code>devcontainer.json</code> tells the tooling how to run it: which user, which mounts, which extensions, which command to run after creation. Keeping the image definition separate from the runtime settings matters, because the Dockerfile can then be used unchanged by CI.</p>\n<pre data-filename=\".devcontainer/Dockerfile\"><code class=\"language-dockerfile\">FROM rust:1.99-slim-bookworm\n\nARG USERNAME=dev\nARG UID=1000\n\nRUN apt-get update \\\n &amp;&amp; apt-get install -y --no-install-recommends git pkg-config libssl-dev gdb ca-certificates \\\n &amp;&amp; rm -rf /var/lib/apt/lists/* \\\n &amp;&amp; useradd --create-home --uid ${UID} --shell /bin/bash ${USERNAME} \\\n &amp;&amp; mkdir -p /usr/local/cargo/registry /workspace /workspace-target \\\n &amp;&amp; chown -R ${USERNAME}:${USERNAME} /usr/local/cargo /workspace /workspace-target\n\nUSER ${USERNAME}\nENV CARGO_TARGET_DIR=/workspace-target\nRUN rustup component add rustfmt clippy</code></pre>\n<p>Read it top to bottom. The base is the official Rust image, pinned to a minor version, so the compiler is the one in the tag and not whatever \"latest\" meant on the day of the build. <code>slim</code> drops compilers and tools you do not need, but it also drops <code>pkg-config</code> and the C library headers that many crates need at build time, so those are installed explicitly: <code>libssl-dev</code> for anything linking OpenSSL, <code>gdb</code> for debugging, <code>git</code> because the editor and cargo both want it. The package lists are deleted in the same <code>RUN</code> step so they do not stay in a layer. A non-root user is created, because a container running as root writes root-owned files into your project folder on Linux hosts, which is the most common complaint about dev containers. The last two lines set the cargo target directory outside the project and add the formatter and linter, which the slim image does not include. The reason for the target directory is covered below, after the bug it caused.</p>\n<pre data-filename=\".devcontainer/devcontainer.json\"><code class=\"language-json\">{\n  \"name\": \"app\",\n  \"build\": { \"dockerfile\": \"Dockerfile\" },\n  \"remoteUser\": \"dev\",\n  \"mounts\": [\n    \"source=app-cargo-registry,target=/usr/local/cargo/registry,type=volume\",\n    \"source=app-target,target=/workspace-target,type=volume\"\n  ],\n  \"runArgs\": [\"--cap-add=SYS_PTRACE\", \"--security-opt\", \"seccomp=unconfined\"],\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"rust-lang.rust-analyzer\", \"vadimcn.vscode-lldb\", \"tamasfe.even-better-toml\"],\n      \"settings\": { \"rust-analyzer.check.command\": \"clippy\" }\n    }\n  },\n  \"postCreateCommand\": \"cargo fetch\"\n}</code></pre>\n<p>I checked that the file parses as JSON; the Dev Containers specification also allows comments in it, and the tooling reads the keys shown. <code>remoteUser</code> makes the editor's terminal and extensions run as <code>dev</code>. The two <code>mounts</code> create named Docker volumes for the cargo registry and the build output, which persist across rebuilds of the container. <code>runArgs</code> adds the capabilities a debugger needs (more on that below). <code>customizations.vscode</code> lists the extensions that are installed inside the container, so the language server runs next to the compiler it needs. And <code>postCreateCommand</code> fetches dependencies once after creation.</p>\n<h2>Build the image and look inside</h2>\n<p>The Dev Containers tooling builds from the Dockerfile; the equivalent by hand is one command:</p>\n<pre><code class=\"language-bash\">docker build -t app-dev -f .devcontainer/Dockerfile .devcontainer</code></pre>\n<p>The build took about 18 seconds on this machine with the base image already pulled. The result is 1.05 GB, against 817 MB for the base image: the extra 230 MB is gdb, git, the OpenSSL headers and the two rustup components. That is a one-time cost per developer, not per build, and layers are shared between images, but it is worth knowing that \"slim\" is not small once you add a debugger. A first command inside confirms who you are and what you have:</p>\n<pre><code class=\"language-bash\">docker run --rm app-dev bash -c 'id -un; id -u; rustc --version; cargo --version'</code></pre>\n<pre><code class=\"language-text\">dev\n1000\nrustc 1.99.1 (58cf8f9c3 2026-10-13)\ncargo 1.99.1 (5f94df478 2026-08-27)</code></pre>\n<p>The user is <code>dev</code> with uid 1000 and the toolchain is 1.99.1. Note that the tag <code>1.99-slim-bookworm</code> floats across patch releases: it was 1.99.1 the day I pulled it, and a later pull could carry 1.99.2. If you need byte-for-byte reproducibility, pin the full version (<code>rust:1.99.1-slim-bookworm</code>) or the image digest, and bump it deliberately.</p>\n<h2>The mistake that cost a build: a fresh volume is owned by root</h2>\n<p>My first <code>devcontainer.json</code> sent the build output to a named volume, <code>app-target</code>, mounted at <code>/workspace-target</code>, so that thousands of build files would not cross a slow host file-sharing layer. The first Dockerfile did not create that directory. I ran the build as the non-root user and got:</p>\n<pre><code class=\"language-text\">error: failed to create directory `/workspace-target/debug`\n\nCaused by:\n  Permission denied (os error 13)</code></pre>\n<p>Listing the mount point showed why: <code>drwxr-xr-x 2 root root</code>. When Docker creates a named volume at a path that does not exist in the image, it creates it as root. If the path <em>does</em> exist in the image, Docker copies that directory's ownership and contents into the empty volume on first use, so the cure is to create the directory in the Dockerfile and give it to the user before the user is switched. That is the <code>mkdir -p ... /workspace-target</code> and the extended <code>chown</code> in the Dockerfile shown above. Do the same for any directory you mount a volume onto. The registry volume worked from the start for exactly this reason: <code>/usr/local/cargo/registry</code> existed in the image and was owned by <code>dev</code> after the <code>chown</code>.</p>\n<p>A smaller lesson hides in the same failure: my first timing script piped cargo's output through <code>grep real</code>, so the failure produced no visible error and a believable \"0.93 seconds\". Whenever you time a command, look at its exit status and output once before trusting the number.</p>\n<h2>What the cache volumes buy</h2>\n<p>With the fix in place I scripted the sequence the volumes exist for. The project depends on <code>serde_json</code>, so the first build has real work to do: download and compile it. Each step ran in a throwaway container, <code>docker run --rm</code>, with the same two volumes:</p>\n<pre><code class=\"language-bash\">RUN=\"docker run --rm -v $PWD:/workspace -w /workspace \\\n  -v app-cargo-registry:/usr/local/cargo/registry \\\n  -v app-target:/workspace-target app-dev\"\n$RUN cargo build      # cold: volumes empty\n$RUN cargo build      # new container, volumes kept\ntouch src/main.rs\n$RUN cargo build      # one source file changed</code></pre>\n<pre><code class=\"language-text\">cold (empty volumes)             2.92 s\nnew container, volumes kept      0.27 s\none source file touched          0.36 s</code></pre>\n<p>The cold build, including the dependency download, took 2.92 seconds. Throw the container away, start another, and the build finished in 0.27 seconds, because the registry and the compiled dependencies were still in the volumes. The container was disposable; the cache was not. That is the design to aim for: containers you can delete without a second thought, and a small set of named volumes that hold the expensive state. This crate is tiny, so the absolute numbers are small; on a project with hundreds of crates the same ratio is the difference between a minute and ten. In the same session <code>cargo test</code> reported <code>2 passed; 0 failed</code>, <code>cargo fmt --check</code> passed and <code>cargo clippy</code> printed nothing, so the formatter and linter installed in the image work for the non-root user.</p>\n<p>I did not measure bind-mounted versus volume build directories on a large project, and the claim that keeping <code>target</code> off the host file share is faster is widely reported for Docker Desktop on macOS and Windows but is not something this guide timed. On a Linux host, where bind mounts are native, putting <code>target</code> in the project folder is fine and lets host tools see it.</p>\n<h2>File ownership on the bind mount</h2>\n<p>The source tree is a bind mount of the host folder, and anything the container writes there belongs to someone on the host. After the run above, the lockfile written through the mount was owned by uid 501 on the host, which is my macOS user, even though the process in the container ran as uid 1000. Docker Desktop for macOS translates ownership across its file-sharing layer, so it just works. On a Linux host there is no translation: a container user with uid 1000 writes files as uid 1000, and if your login is uid 1001 you will find files you cannot edit. The Dockerfile accepts a <code>UID</code> build argument for that, and the Dev Containers tooling has an <code>updateRemoteUserUID</code> option (on by default on Linux) that remaps the container user to match the host at start-up. I could not test the Linux case here, so treat it as the documented behaviour.</p>\n<h2>The toolchain: image tag or rust-toolchain.toml, not both</h2>\n<p>A project that already pins its compiler in <code>rust-toolchain.toml</code> meets the image's compiler as a second source of truth. To see what happens, I added a file requesting <code>1.99.0</code> and ran <code>cargo --version</code> in the container, whose installed version is 1.99.1:</p>\n<pre><code class=\"language-text\">info: syncing channel updates for 1.99.0-aarch64-unknown-linux-gnu\ninfo: latest update on 2026-10-01 for version 1.99.0 (b940084d7 2026-09-28)\ninfo: downloading 3 components\nwarn: the missing active toolchain `1.99.0-aarch64-unknown-linux-gnu` has been auto-installed</code></pre>\n<p>rustup obeyed the project file and downloaded a second toolchain into the container's home directory, at run time, in the container's writable layer. It worked, and it is the wrong place for it: the download repeats in every fresh container, it is invisible in the image, and the editor may start before it finishes. Pick one source of truth. For a team that wants the container to be the environment, set the Dockerfile's base tag to the version in <code>rust-toolchain.toml</code> and keep them equal in review; or install the toolchain from the file during the build with <code>RUN rustup show</code> in the project directory so it is baked in. For a team that supports contributors who do not use the container, keep <code>rust-toolchain.toml</code> authoritative and let the image follow it.</p>\n<h2>Choosing what to install in the image</h2>\n<p>The package list in the Dockerfile is the part that grows. A useful test for each line is whether a build or a test fails without it. <code>pkg-config</code> and <code>libssl-dev</code> pass that test for any crate that links OpenSSL, and a missing one shows up as a build-script error naming the library, which is easy to trace back to a package. Developer conveniences such as a shell prompt, <code>ripgrep</code> or <code>jq</code> fail the test, and they belong either in a personal dotfiles setup that the editor can apply to every container, or in a second image layer that only developers use. Keeping the base image to what the build needs also means the CI image, which is the same file, stays small and its cache stays warm. When you do add a tool, add it with a pinned version where the tool allows, such as <code>cargo install --locked cargo-deny --version 0.20.2</code>, so the environment is reproducible and a new release of a helper does not change what your contributors see.</p>\n<p>Prebuilt features are the alternative to writing a Dockerfile. The Dev Containers specification lets <code>devcontainer.json</code> list features such as a Rust toolchain or Git, which the tooling layers onto a base image. They are convenient for a quick start and give you less control over exactly what is installed. I used a Dockerfile because it is the more transparent choice and the one a CI job can build without the Dev Containers tooling, and I did not test the features mechanism.</p>\n<h2>Debugging inside a container</h2>\n<p>A debugger uses <code>ptrace</code>, and Docker's default seccomp profile and capability set have historically blocked it, which is why the example <code>devcontainer.json</code> adds <code>--cap-add=SYS_PTRACE</code> and <code>seccomp=unconfined</code>. I ran <code>gdb</code> in batch mode against the built binary twice, once with the defaults and once with those flags. In both runs the program ran under gdb and exited normally, with no ptrace error:</p>\n<pre><code class=\"language-text\">total = 0\n[Inferior 1 (process 18) exited normally]\nNo stack.</code></pre>\n<p>So on Docker Desktop for Mac the flags were not needed for gdb to launch a process. I would not conclude that they are never needed: that Docker runs containers inside a Linux virtual machine with its own defaults, and a Linux host with Docker Engine and the standard seccomp profile is the case where debuggers have been reported to fail. The program in that test also ran without arguments and no breakpoint was hit, which means this was a check that gdb can start a process, not a debugging session. Keep the flags if your team includes Linux hosts, and note that <code>seccomp=unconfined</code> weakens the container's isolation, which matters if you run untrusted code in it.</p>\n<h2>Using the same image in CI</h2>\n<p>A dev container pays for itself twice if CI runs the same image. The commands in the container are ordinary cargo, so a CI job needs no special tooling beyond running them in the image built from the same Dockerfile:</p>\n<pre data-filename=\"ci step\"><code class=\"language-bash\">docker build -t app-dev -f .devcontainer/Dockerfile .devcontainer\ndocker run --rm -v \"$PWD:/workspace\" -w /workspace app-dev \\\n  bash -c 'cargo fmt --check &amp;&amp; cargo clippy --all-targets -- -D warnings &amp;&amp; cargo test'</code></pre>\n<p>When a check fails in CI and passes locally, the usual cause is a different environment; here there is none to differ. The cost is image build time, which a registry cache or a prebuilt image removes. Most CI systems can also run a job directly in a container image, which skips the build step if you publish the image.</p>\n<h2>A day-to-day routine</h2>\n<ul>\n<li><strong>Open the folder in the container</strong> from the editor's command palette (\"Reopen in Container\"). The editor builds the image the first time and reuses it after. I did not run this step.</li>\n<li><strong>Rebuild the container</strong> after any change to the Dockerfile or <code>devcontainer.json</code>. Volumes survive a rebuild; only the image is replaced.</li>\n<li><strong>Reset the caches</strong> when a build behaves strangely: <code>docker volume rm app-cargo-registry app-target</code>. The next build is cold, and nothing else is lost.</li>\n<li><strong>Add system packages in the Dockerfile</strong>, not by hand in a running container. A package installed by hand disappears at the next rebuild, which is a surprise for the person who added it.</li>\n<li><strong>Keep secrets out of the image.</strong> Pass them at run time. The image is built from files in the repository and may be pushed to a registry.</li>\n</ul>\n<h2>Where dev containers are the wrong tool</h2>\n<ul>\n<li><strong>Heavy native work on macOS or Windows.</strong> File sharing across the virtual machine boundary is slower than a native disk, and the build directory in a volume only helps with compile output. If rust-analyzer feels slow on a large workspace, the cause may be the mount, and a native toolchain with the Dockerfile used only for CI is a valid choice.</li>\n<li><strong>GUI and device work.</strong> Anything that needs a display, a USB programmer or a GPU needs extra configuration, and the embedded guides on this site flash hardware from the host for this reason.</li>\n<li><strong>Cross-architecture surprises.</strong> The image here is aarch64 Linux because the host is an M1. A teammate on x86-64 pulls a different build of the same tag. Behaviour is usually identical; inline assembly and performance numbers are not.</li>\n<li><strong>The editor part is unverified.</strong> The Dockerfile, the volumes, the user, the toolchain behaviour and the cargo commands were run. The extension installation, port forwarding and the editor's own settings were not, because the Dev Containers extension is not available on this machine.</li>\n<li><strong>One container, one project.</strong> Several services (a database and the app) call for Docker Compose and a different <code>devcontainer.json</code> shape, which this guide does not cover.</li>\n</ul>\n<p>The measured facts are these: the image builds in about 18 seconds to 1.05 GB, a fresh volume mounted at a path the image does not contain is owned by root and breaks a non-root build until the Dockerfile creates and owns that path, a named volume turns a 2.92-second cold build into a 0.27-second one in a new container, and a conflicting <code>rust-toolchain.toml</code> silently downloads a second compiler at run time. Each of those is a line in a file you commit.</p>","date_published":"2026-11-03T10:04:00+00:00","tags":["DevOps"]},{"id":"https://rusthacks.dev/library/rust-ci-test-format-lint","url":"https://rusthacks.dev/library/rust-ci-test-format-lint","title":"Continuous Integration for Rust Test Format Lint Build","summary":"A good Rust pipeline runs the same few checks in the same order every time. Format, clippy, tests, docs and a cache cover most projects in about forty lines of YAML.","content_html":"<p>A Rust continuous integration pipeline is a short list of commands that must pass before code merges, run in the same order on every push. The commands are not the hard part; any team can write <code>cargo test</code> into a workflow file. The hard part is choosing the set that catches real problems without becoming a twenty-minute wait that people learn to ignore, and knowing which failure each step exists to catch. This guide builds that pipeline around a small crate: formatting, clippy, tests, documentation, a minimum supported Rust version (MSRV) check and a dependency policy check. Every step was run locally, a failure was provoked on purpose for each one, and the real output is shown, so you know what a red build will look like before you meet one. The toolchain is rustc 1.99.0, cargo-deny 0.20.2, edition 2024, on an Apple M1 Max. The YAML for GitHub Actions at the end was not run on GitHub's servers; the commands inside it were all run here, and the guide says where that distinction matters.</p>\n<h2>The crate under test</h2>\n<p>The subject is a tiny library that counts words. It has a unit test, a doctest, and a documented public function, which is enough to give every check something to do:</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">//! Word frequency counting.\n\nuse std::collections::HashMap;\n\n/// Counts how often each lowercase word appears.\n///\n/// ```\n/// let counts = ci::count_words(\"the cat the hat\");\n/// assert_eq!(counts[\"the\"], 2);\n/// ```\npub fn count_words(text: &amp;str) -&gt; HashMap&lt;String, usize&gt; {\n    let mut counts = HashMap::new();\n    for word in text.split_whitespace() {\n        *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n    }\n    counts\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn counts_case_insensitively() {\n        let c = count_words(\"Go go GO\");\n        assert_eq!(c[\"go\"], 3);\n    }\n}</code></pre>\n<p>Two files outside the source pin the environment. A <code>rust-toolchain.toml</code> names the compiler and its components, so the version on a laptop and the version in CI cannot drift apart. And the manifest declares <code>rust-version</code>, the oldest compiler the crate promises to support:</p>\n<pre data-filename=\"rust-toolchain.toml\"><code class=\"language-toml\">[toolchain]\nchannel = \"1.99.0\"\ncomponents = [\"rustfmt\", \"clippy\"]</code></pre>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[package]\nname = \"ci\"\nversion = \"0.1.0\"\nedition = \"2024\"\nlicense = \"MIT\"\nrust-version = \"1.85\"\n\n[dependencies]\nserde = { version = \"1.0.229\", features = [\"derive\"] }\nserde_json = \"1.0.154\"</code></pre>\n<p>Pinning the toolchain to an exact version is the single most useful decision in the file. A pipeline on <code>stable</code> fails on a day nobody touched the code, because a new compiler release added a lint. That failure is real information, but it should arrive as a deliberate update pull request, not as a surprise on someone else's unrelated change.</p>\n<h2>Step one: formatting</h2>\n<p><code>cargo fmt --check</code> exits non-zero and prints a diff if any file differs from rustfmt's output. It changes nothing, which is why it belongs in CI; the fix is for a developer to run <code>cargo fmt</code> locally. To see what the failure looks like I removed the spaces around one <code>=</code>:</p>\n<pre><code class=\"language-text\">Diff in src/lib.rs:9:\n /// assert_eq!(counts[\"the\"], 2);\n /// ```\n pub fn count_words(text: &amp;str) -&gt; HashMap&lt;String, usize&gt; {\n-    let mut counts=HashMap::new();\n+    let mut counts = HashMap::new();\n     for word in text.split_whitespace() {\n         *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n     }</code></pre>\n<p>The exit code was 1. Run this first. It takes under a second, it needs no compilation, and a formatting failure should never cost a contributor a full build before they learn about it. The same ordering logic applies to the rest of the list: cheapest and most likely to fail goes first.</p>\n<h2>Step two: clippy as errors</h2>\n<p>Clippy catches code that compiles but is probably not what you meant. In CI it should fail the build, otherwise warnings accumulate until nobody reads them. The flags matter. <code>--all-targets</code> includes tests, examples and benches, which a plain <code>cargo clippy</code> skips. <code>-- -D warnings</code> promotes every warning to an error. I introduced a classic: calling <code>.into_iter()</code> on something that is already an iterator.</p>\n<pre><code class=\"language-text\">error: useless conversion to the same type: `std::str::SplitWhitespace&lt;'_&gt;`\n  --&gt; src/lib.rs:13:17\n   |\n13 |     for word in text.split_whitespace().into_iter() {\n   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: consider removing `.into_iter()`: `text.split_whitespace()`\n   |\n   = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#useless_conversion\n   = note: `-D clippy::useless-conversion` implied by `-D warnings`\n   = help: to override `-D warnings` add `#[allow(clippy::useless_conversion)]`\n\nerror: could not compile `ci` (lib test) due to 1 previous error\n</code></pre><p>A note on the pinned toolchain and clippy: the help link in that message points at <code>rust-1.99.0</code> documentation, because the lint list is versioned with the compiler. Another reason to pin. The output above is trimmed to the first error; clippy printed a second, identical one for the library build.</p>\n<h2>Step three: tests, including doctests</h2>\n<p><code>cargo test</code> runs unit tests, integration tests and doctests in one command, which is why nobody should replace it with a custom script. Doctests are the part teams forget: they compile your documentation examples as tiny programs, so a README-style example that no longer builds fails here. I changed the expected count in the unit test from 3 to 2:</p>\n<pre><code class=\"language-text\">failures:\n\n---- tests::counts_case_insensitively stdout ----\n\nthread 'tests::counts_case_insensitively' panicked at src/lib.rs:26:9:\nassertion `left == right` failed\n  left: 3\n right: 2\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nfailures:\n    tests::counts_case_insensitively\n\ntest result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s</code></pre>\n<p>Two flags are worth adding in CI. <code>--locked</code> makes cargo fail if <code>Cargo.lock</code> would need to change, which catches a dependency edit committed without its lockfile update. And for a library crate you also want <code>cargo test --no-default-features</code> and <code>--all-features</code> runs if the crate has features, because the default feature set is the one combination nobody forgets to test.</p>\n<h2>Why --locked earns its place</h2>\n<p>The <code>--locked</code> flag deserves a demonstration, because the failure is quiet in its absence. I added a new line to the dependency table, <code>fastrand = \"2\"</code>, without regenerating the lockfile, then ran the tests the way CI would:</p>\n<pre><code class=\"language-text\">    Updating crates.io index\nerror: cannot update the lock file Cargo.lock because --locked was passed to prevent this\nhelp: to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.</code></pre>\n<p>Without the flag, cargo would have resolved the new crate on the build machine, written a lockfile nobody committed, and gone green. The next developer to pull the branch would then resolve differently. With the flag, the pull request fails until the manifest and the lockfile agree. My first attempt at this experiment, adding <code>itoa</code>, did <em>not</em> fail, and the reason is a useful caution: <code>itoa</code> was already in the lockfile as a dependency of <code>serde_json</code>, so the lock did not need to change. The flag only objects when resolution would produce a different file.</p>\n<h2>What the cache holds, and what it cannot</h2>\n<p>After the runs above the <code>target</code> directory of this tiny crate was 79 MB. Almost all of it is the compiled output of serde, serde_json, syn, quote and proc-macro2, none of which changed between runs. That is what a CI cache restores, and it is why the 6.47 second cold run collapsed to 0.74 seconds on the second run. Three habits keep the cache honest:</p>\n<ul>\n<li><strong>Key it on the lockfile and the compiler.</strong> A cache saved under one compiler version is useless under another, because compiled artefacts are not portable across versions. This is a second reason to pin the toolchain.</li>\n<li><strong>Do not cache your own crates.</strong> Workspace members change on every commit, so restoring them only grows the archive. The cache action named in the workflow prunes them for that reason.</li>\n<li><strong>Keep the flags identical across jobs.</strong> Cargo fingerprints the flags that built each artefact. A job that sets <code>RUSTFLAGS=\"-D warnings\"</code> and a job that does not will not share compiled dependencies, so a mismatch silently halves the benefit of the cache.</li>\n</ul>\n<p>The cache has one limit that no setting fixes: the first build after the lockfile changes is cold. A pull request that updates a core dependency pays the full price once, and every later run is warm again. Plan around it and do not read one slow run as a regression.</p>\n<h2>Making the workflow itself safe</h2>\n<p>A workflow file is code that runs with credentials, and a pipeline for an open-source crate runs on pull requests from strangers. The YAML above is the functional part; these changes harden it, and none of them alter what the checks do:</p>\n<pre data-filename=\".github/workflows/ci.yml (additions)\"><code class=\"language-yaml\">permissions:\n  contents: read\n\nconcurrency:\n  group: ci-${{ github.ref }}\n  cancel-in-progress: true</code></pre>\n<p><code>permissions: contents: read</code> reduces the default token to read-only, so a compromised step cannot push code or edit releases. <code>concurrency</code> with <code>cancel-in-progress</code> stops the previous run when a new commit arrives on the same branch, which saves minutes on a busy pull request. For stricter supply-chain hygiene, pin third-party actions to a full commit SHA rather than a tag, since a tag can be moved. Only publish jobs should ever see a secret; a test job needs none, and a pull request from a fork does not receive them at all.</p>\n<h2>Reading a red build</h2>\n<p>The value of a pipeline is decided on the day it fails, so shape the output for that day. Keep each check as its own step with its own name, so the first red line in the summary says \"clippy\" or \"docs\" without anyone opening a log. Print the diff for formatting (the default does). Keep test output on failure only; the standard harness already hides the output of passing tests. And resist the pull to put every command into one <code>make ci</code> line: a single step that does seven things fails with seven possible meanings. Separate steps cost nothing in time because they share the same machine and the same cache.</p>\n<p>When a check fails only in CI, the first suspects are always the same three: a different compiler version (check the pin), a different feature set (check <code>--all-features</code> and the default features), and a different operating system. A pipeline that logs <code>rustc --version --verbose</code> and the exact test command at the top of the job makes the first of those a ten-second check. Add that line; it is one line of YAML and it ends more arguments than any other.</p>\n<h2>Step four: documentation that builds clean</h2>\n<p>Broken intra-doc links are silent in a normal build and show up as dead text on docs.rs. <code>cargo doc</code> with <code>RUSTDOCFLAGS=\"-D warnings\"</code> turns them into failures. <code>--no-deps</code> skips documenting every dependency, which is slow and not your problem. I linked to a function that does not exist:</p>\n<pre><code class=\"language-text\">error: unresolved link to `missing_fn`\n --&gt; src/lib.rs:5:57\n  |\n5 | /// Counts how often each lowercase word appears, see [`missing_fn`].\n  |                                                         ^^^^^^^^^^ no item named `missing_fn` in scope\n  |\n  = note: `-D rustdoc::broken-intra-doc-links` implied by `-D warnings`\n  = help: to override `-D warnings` add `#[allow(rustdoc::broken_intra_doc_links)]`\n\nerror: could not document `ci`</code></pre>\n<h2>Step five: the minimum supported Rust version</h2>\n<p>If the manifest says <code>rust-version = \"1.85\"</code>, something must check it, or the number is a guess. The check is to build with exactly that compiler. The first instinct, to set the MSRV to whatever is oldest that still works, runs into a floor set by the language edition. I tried 1.84.0 on this crate:</p>\n<pre><code class=\"language-text\">error: failed to parse manifest at `Cargo.toml`\n\nCaused by:\n  feature `edition2024` is required</code></pre>\n<p>Edition 2024 shipped in 1.85, so no compiler older than that can read the manifest, and 1.85 is the honest floor for this crate. With <code>cargo +1.85.0 check</code> the build finished cleanly in 5.49 seconds on a cold target directory. In CI, install that exact toolchain in a separate job and run <code>cargo check --locked --all-features</code> with it. Use <code>check</code>, not <code>test</code>: dev-dependencies are allowed to need newer compilers than the library itself, and a compile check is enough to prove the promise you made to users. Raising the MSRV is a normal, deliberate change, but it should be a change someone made on purpose, which is what the job enforces.</p>\n<h2>Step six: dependency policy with cargo-deny</h2>\n<p>Everything so far checks your code. <code>cargo deny</code> checks what you depend on: licences, known security advisories, banned crates and duplicate versions. A minimal <code>deny.toml</code> that allows only two licences looks reasonable and fails on the first run:</p>\n<pre data-filename=\"deny.toml\"><code class=\"language-toml\">[licenses]\nallow = [\"MIT\", \"Apache-2.0\", \"Unicode-3.0\"]\n\n[bans]\nmultiple-versions = \"warn\"</code></pre>\n<p>With only <code>MIT</code> and <code>Apache-2.0</code> on the list, <code>cargo deny check licenses bans</code> printed <code>bans ok, licenses FAILED</code>. One cause was my own crate: it had no <code>license</code> field, and cargo-deny reports \"ci = 0.1.0 is unlicensed\" as an error. The other was a transitive dependency of serde under <code>Unicode-3.0</code>, which I had not allowed. After adding the field and the licence to the list:</p>\n<pre><code class=\"language-text\">bans ok, licenses ok</code></pre>\n<p>This is the most useful lesson from the step: a policy you write from memory is wrong the first time you run it, and the failure tells you something about your real dependency tree. Run it once locally, fix the list, then commit it. The advisory check (<code>cargo deny check advisories</code>) needs to fetch the RustSec database, so it is the one check that fails for network reasons, not code reasons; schedule it nightly in addition to running it on pull requests, because a new advisory can appear against code that has not changed.</p>\n<h2>Putting the order together</h2>\n<p>The local version of the whole pipeline is a short script. Order is by cost and by likelihood of failure:</p>\n<pre data-filename=\"pipeline.sh\"><code class=\"language-bash\">set -e\ncargo fmt --check\ncargo clippy --all-targets --locked -- -D warnings\ncargo test --locked\nRUSTDOCFLAGS=\"-D warnings\" cargo doc --no-deps --locked</code></pre>\n<p>I timed it twice on this crate, after adding serde and serde_json as dependencies. With an empty target directory the whole script took 6.47 seconds. Run again immediately, with the build artefacts in place, it took 0.74 seconds. The gap between those two numbers is the entire argument for caching in CI: almost all of a Rust pipeline's time is compiling dependencies that did not change. The crate here is tiny, so the cold run is short; on a project with a few hundred crates the same ratio turns six seconds into ten minutes.</p>\n<h2>The workflow file</h2>\n<p>This is the GitHub Actions version. I did not run it on GitHub; each command in it is one I ran above, and the structure follows the actions' documented inputs. The decisions to notice are the pinned toolchain, one cache action, a separate MSRV job and <code>--locked</code> everywhere:</p>\n<pre data-filename=\".github/workflows/ci.yml\"><code class=\"language-yaml\">name: ci\non:\n  push:\n    branches: [main]\n  pull_request:\n\nenv:\n  CARGO_TERM_COLOR: always\n  RUSTFLAGS: \"-D warnings\"\n\njobs:\n  check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.99.0\n          components: rustfmt, clippy\n      - uses: Swatinem/rust-cache@v2\n      - run: cargo fmt --check\n      - run: cargo clippy --all-targets --locked -- -D warnings\n      - run: cargo test --locked\n      - run: cargo doc --no-deps --locked\n        env:\n          RUSTDOCFLAGS: \"-D warnings\"\n\n  msrv:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.85.0\n      - uses: Swatinem/rust-cache@v2\n      - run: cargo check --locked --all-features\n\n  deny:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: EmbarkStudios/cargo-deny-action@v2</code></pre>\n<p>Why each choice. <code>RUSTFLAGS: \"-D warnings\"</code> at the job level makes the compiler's own warnings fatal, so an unused import fails the build the same way a clippy lint does. The cache action keys on the lockfile and the compiler version, restoring <code>~/.cargo</code> and <code>target</code>, and it prunes workspace crates so the cache does not grow without bound. Running <code>msrv</code> and <code>deny</code> as separate jobs lets them run in parallel with the main job instead of extending it, and gives each a clear name when it goes red. One more change to avoid is putting <code>cargo build --release</code> in the pull-request path; a release build is for the deploy job, and it doubles the wall time without testing anything the debug build did not.</p>\n<h2>Run the same thing before you push</h2>\n<p>A pipeline that only runs in the cloud trains people to push and wait. The commands in this guide are all plain cargo, so the cheapest improvement is to make the local and remote versions identical. Keep the four-line script from earlier in the repository and call it from both the workflow and a git pre-push hook, or from a task runner target everyone knows. The point is not enforcement. It is that a contributor who sees a red clippy error on their own machine, in under a second on a warm build, fixes it before it becomes a review comment, and a pull request that arrives already green spends its reviewers' attention on the design instead of on whitespace. When the local script and the workflow ever disagree, treat that as a bug in one of them, because the script is the definition of passing and the workflow is only the place it is enforced.</p>\n<h2>Where this pipeline stops</h2>\n<p>A pipeline of these six steps is a floor, not a ceiling, and some advice does not fit every project:</p>\n<ul>\n<li><strong>Passing is not coverage.</strong> The steps prove the code compiles, lints clean and passes the tests it has. They say nothing about the tests it lacks. Add coverage reporting or mutation testing if you need that answer, and treat the percentage as a question, not a target.</li>\n<li><strong>Pinning the compiler is a trade.</strong> You get reproducible builds and you accept the chore of bumping the version. Some teams run a second, scheduled job on <code>stable</code> and <code>beta</code> to see new lints coming; that job should be allowed to fail without blocking merges.</li>\n<li><strong>Applications and libraries differ.</strong> A library should test with <code>--no-default-features</code> and <code>--all-features</code>, and should not commit <code>Cargo.lock</code> to be consumed. An application should commit it and use <code>--locked</code> everywhere, which is why the script above uses it.</li>\n<li><strong>One operating system is a choice.</strong> If your code reads paths, spawns processes or uses sockets, a Linux-only pipeline will not catch a Windows or macOS failure. Add a matrix, and pay for it in time.</li>\n<li><strong>Fast checks first, but not only fast checks.</strong> Failing fast on formatting is good; it is not a reason to skip the slow integration tests, which belong in the pipeline even if they run last.</li>\n</ul>\n<p>The measurements that justify the shape are the ones above: formatting fails in under a second with a readable diff, a clippy error tells you the lint and the replacement, a failing test prints both values, a broken doc link names the missing item, and a pinned MSRV makes a language-edition floor visible instead of a surprise. The cold run of 6.47 seconds against a warm run of 0.74 shows why the cache step pays for itself, and the failed licence check on the first attempt shows why the dependency policy should be run on your real tree before you commit it.</p>","date_published":"2026-11-03T10:02:00+00:00","tags":["Testing"]},{"id":"https://rusthacks.dev/library/rust-vector-search-pgvector","url":"https://rusthacks.dev/library/rust-vector-search-pgvector","title":"Vector Search From Rust With pgvector and Embeddings","summary":"pgvector adds a vector type and nearest-neighbor operators to Postgres. From Rust you store embeddings as arrays, index them with HNSW and query with an ORDER BY on distance.","content_html":"<p>A search box that understands meaning, a \"related posts\" panel, a retrieval step for a question-answering bot: all of them reduce to the same operation. Turn each document into a list of numbers (an <em>embedding</em>) such that similar documents get nearby lists, store the lists, and when a query arrives, turn it into a list too and ask for the closest stored ones. PostgreSQL does this with the <code>pgvector</code> extension, which adds a <code>vector</code> column type, distance operators and approximate indexes, so the vectors live next to the rest of your data, in transactions, with your backups. This guide builds the whole path from Rust: an extension in a container, a table, inserts, the three distance operators and how they relate, a measured comparison of exact search against an HNSW index (including how much accuracy you give up for speed), the filtered-search trap that returns zero rows, and the operational facts that decide whether it stays fast. Everything ran with Rust 1.99, SQLx 0.9.0, the <code>pgvector</code> crate 0.4.2 and PostgreSQL 17 with pgvector 0.8.7, on an Apple M1 Max with the database in a container. One piece is deliberately a stand-in, the embedding model, and the text says where.</p>\n<h2>What an embedding search is</h2>\n<p>An embedding model maps text (or images, or audio) to a vector with a fixed number of dimensions, say 384 or 1,536, so that the geometric distance between two vectors reflects how similar their meanings are. \"Rust async runtime\" lands close to \"tokio executor\" and far from \"sourdough starter\". The database does not know anything about meaning. It stores vectors and answers \"which stored vectors are nearest to this one?\", and the quality of the answers is entirely the model's. That separation is useful for design: the database layer (this guide) is the same whichever model you pick, and you can swap models by re-computing the vectors.</p>\n<p>Nearest means a choice of distance. pgvector offers three operators that you will use:</p>\n<table>\n<thead><tr><th>Operator</th><th>Distance</th><th>Use when</th></tr></thead>\n<tbody>\n<tr><td><code>&lt;=&gt;</code></td><td>Cosine distance (1 minus cosine similarity)</td><td>The default for text embeddings; direction matters, length does not</td></tr>\n<tr><td><code>&lt;-&gt;</code></td><td>Euclidean (L2) distance</td><td>Vectors where magnitude is meaningful, or the model says so</td></tr>\n<tr><td><code>&lt;#&gt;</code></td><td>Negative inner product</td><td>Vectors already normalised to length 1 (fastest, and equivalent to cosine for them)</td></tr>\n</tbody>\n</table>\n<p>The negation in <code>&lt;#&gt;</code> exists because Postgres indexes return the smallest value first, so a larger inner product (more similar) has to sort as a smaller number. Follow the model's documentation: most text embedding models are trained for cosine similarity.</p>\n<h2>Set up the database</h2>\n<p>Run PostgreSQL in a container, add the extension and create it in your database. On the official Debian-based image the extension is a package from the Postgres apt repository that the image already trusts; there are also ready-made images with it built in:</p>\n<pre><code class=\"language-bash\">docker run -d --name pg --shm-size=1g -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app -p 55433:5432 postgres:17\ndocker exec pg bash -c \"apt-get update -qq &amp;&amp; apt-get install -y -qq postgresql-17-pgvector\"\ndocker exec pg psql -U postgres -d app -c \"CREATE EXTENSION vector;\" -c \"SELECT extversion FROM pg_extension WHERE extname = 'vector';\"</code></pre>\n<p>Two details come from running it. <code>--shm-size=1g</code> is not decoration: Docker gives a container 64 MB of shared memory by default, and building a vector index in parallel needs more. With the default, the index build in this guide failed with <code>could not resize shared memory segment ... No space left on device</code>, an error that mentions disk and is actually about <code>/dev/shm</code>. The other is the extension version, 0.8.7 here; features such as iterative index scans (used below) need pgvector 0.8 or newer, so check <code>extversion</code> before you rely on them.</p>\n<h2>The Rust side</h2>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[dependencies]\nsqlx = { version = \"0.9\", features = [\"runtime-tokio\", \"postgres\"] }\npgvector = { version = \"0.4\", features = [\"sqlx\"] }\ntokio = { version = \"1\", features = [\"full\"] }</code></pre>\n<p>The <code>pgvector</code> crate provides a <code>Vector</code> type with SQLx <code>Encode</code> and <code>Decode</code> implementations behind its <code>sqlx</code> feature, so you can bind and read vectors like any other column type; it supports SQLx 0.8 and 0.9. The first program creates a table, embeds seven short documents, and queries with each operator:</p>\n<pre data-filename=\"src/bin/demo.rs\"><code class=\"language-rust\">use pgvector::Vector;\nuse sqlx::{AssertSqlSafe, PgPool};\n\nconst DIMS: usize = 64;\n\n/// A toy embedding: hash each word into one of 64 buckets with a sign, then L2-normalise.\n/// A real system would call an embedding model here; everything after this function is unchanged.\nfn embed(text: &amp;str) -&gt; Vector {\n    let mut v = vec![0f32; DIMS];\n    for word in text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {\n        let mut h: u64 = 0xcbf29ce484222325;\n        for b in word.to_lowercase().bytes() {\n            h = (h ^ u64::from(b)).wrapping_mul(0x100000001b3);\n        }\n        let sign = if (h &gt;&gt; 63) &amp; 1 == 0 { 1.0 } else { -1.0 };\n        v[(h % DIMS as u64) as usize] += sign;\n    }\n    let norm = v.iter().map(|x| x * x).sum::&lt;f32&gt;().sqrt().max(1e-9);\n    Vector::from(v.into_iter().map(|x| x / norm).collect::&lt;Vec&lt;f32&gt;&gt;())\n}\n\n#[tokio::main]\nasync fn main() -&gt; Result&lt;(), sqlx::Error&gt; {\n    let pool = PgPool::connect(&amp;std::env::var(\"DATABASE_URL\").unwrap()).await?;\n    sqlx::query(\"CREATE EXTENSION IF NOT EXISTS vector\").execute(&amp;pool).await?;\n    sqlx::query(\"DROP TABLE IF EXISTS docs\").execute(&amp;pool).await?;\n    sqlx::query(\"CREATE TABLE docs (id bigserial PRIMARY KEY, body text NOT NULL, embedding vector(64) NOT NULL)\").execute(&amp;pool).await?;\n\n    let docs = [\n        \"tokio is an async runtime for rust network services\",\n        \"async await in rust compiles to state machines\",\n        \"how to bake sourdough bread with a long cold ferment\",\n        \"rust ownership and borrowing explained with examples\",\n        \"postgres index types btree gin gist and hnsw\",\n        \"a recipe for slow roasted tomato soup with basil\",\n        \"vector search finds nearest neighbours by distance\",\n    ];\n    for body in docs {\n        sqlx::query(\"INSERT INTO docs (body, embedding) VALUES ($1, $2)\").bind(body).bind(embed(body)).execute(&amp;pool).await?;\n    }\n\n    for (label, op) in [(\"cosine distance  &lt;=&gt;\", \"&lt;=&gt;\"), (\"euclidean (L2)  &lt;-&gt;\", \"&lt;-&gt;\"), (\"negative inner  &lt;#&gt;\", \"&lt;#&gt;\")] {\n        let q = embed(\"async rust runtime\");\n        let sql = format!(\"SELECT body, embedding {op} $1 AS dist FROM docs ORDER BY embedding {op} $1 LIMIT 3\");\n        let rows: Vec&lt;(String, f64)&gt; = sqlx::query_as(AssertSqlSafe(sql)).bind(&amp;q).fetch_all(&amp;pool).await?;\n        println!(\"{label}\");\n        for (body, dist) in rows {\n            println!(\"  {dist:&gt;7.4}  {body}\");\n        }\n    }\n    Ok(())\n}\n</code></pre>\n<pre><code class=\"language-text\">cosine distance  &lt;=&gt;\n   0.4226  tokio is an async runtime for rust network services\n   0.5918  async await in rust compiles to state machines\n   0.7818  rust ownership and borrowing explained with examples\neuclidean (L2)  &lt;-&gt;\n   0.9194  tokio is an async runtime for rust network services\n   1.0879  async await in rust compiles to state machines\n   1.2504  rust ownership and borrowing explained with examples\nnegative inner  &lt;#&gt;\n  -0.5774  tokio is an async runtime for rust network services\n  -0.4082  async await in rust compiles to state machines\n  -0.2182  rust ownership and borrowing explained with examples</code></pre>\n<p>Look at the structure before the numbers. The <code>embed</code> function is the only model-dependent code; it takes text and returns a <code>Vector</code>. Here it is a toy: it hashes each word into one of 64 buckets with a random sign and normalises the result, which makes texts that share words land close together but knows nothing about synonyms. It exists so the example runs with no API key and no model download, and it is not a substitute: in a real system you replace the body with a call to an embedding service or a local model, keep the dimensions in sync with the column (<code>vector(64)</code> becomes <code>vector(1536)</code>, say), and everything else in this guide is unchanged. The results are still sensible, because the query \"async rust runtime\" shares words with the first two documents, and the bread and soup recipes do not appear.</p>\n<p>Now the numbers. All three operators ranked the same three documents in the same order, which is expected for unit vectors, and the values relate exactly: the first result has cosine distance 0.4226, so its inner product is 1 - 0.4226 = 0.5774, which is the (negated) value in the third block, and its Euclidean distance is the square root of 2 x 0.4226, which is 0.9194. For normalised vectors the three metrics are the same ranking in different units, so choose by speed and by what your index was built with. A second Rust detail from this build: SQLx 0.9 refuses a runtime-built SQL string at compile time (\"dynamic SQL strings should be audited for possible injections\"), because <code>query()</code> now only takes literals. The demo builds its query text from a fixed list of operators, so it wraps it in <code>AssertSqlSafe(...)</code>, which is you promising that nothing from outside went into the string. Values always go through <code>bind</code>, never through the string.</p>\n<h2>Exact search is fine until it is not</h2>\n<p>Without an index, <code>ORDER BY embedding &lt;=&gt; $1 LIMIT 10</code> compares the query with every row: an exact answer, and a cost that grows with the table. The benchmark loads 100,000 vectors of 128 dimensions, drawn from 100 clusters (real embeddings cluster; uniformly random points in high dimensions are a misleading test), and times 30 queries:</p>\n<pre data-filename=\"src/bin/bench.rs\"><code class=\"language-rust\">use pgvector::Vector;\nuse sqlx::{AssertSqlSafe, PgPool, QueryBuilder, Postgres, Row};\nuse std::time::Instant;\n\nconst DIMS: usize = 128;\nconst N: usize = 100_000;\nconst CLUSTERS: usize = 100;\n\nstruct Rng(u64);\nimpl Rng {\n    fn next(&amp;mut self) -&gt; f32 {\n        self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);\n        ((self.0 &gt;&gt; 40) as f32 / (1u64 &lt;&lt; 24) as f32) * 2.0 - 1.0\n    }\n}\n\nfn normalise(mut v: Vec&lt;f32&gt;) -&gt; Vec&lt;f32&gt; {\n    let n = v.iter().map(|x| x * x).sum::&lt;f32&gt;().sqrt();\n    v.iter_mut().for_each(|x| *x /= n);\n    v\n}\n\nfn point(rng: &amp;mut Rng, centers: &amp;[Vec&lt;f32&gt;]) -&gt; Vec&lt;f32&gt; {\n    let c = &amp;centers[((rng.next() + 1.0) * 0.5 * CLUSTERS as f32) as usize % CLUSTERS];\n    normalise(c.iter().map(|x| x + 0.35 * rng.next()).collect())\n}\n\nasync fn top10(pool: &amp;PgPool, q: &amp;Vector) -&gt; Result&lt;Vec&lt;i64&gt;, sqlx::Error&gt; {\n    let rows = sqlx::query(\"SELECT id FROM items ORDER BY embedding &lt;=&gt; $1 LIMIT 10\").bind(q).fetch_all(pool).await?;\n    Ok(rows.iter().map(|r| r.get::&lt;i64, _&gt;(0)).collect())\n}\n\n#[tokio::main]\nasync fn main() -&gt; Result&lt;(), sqlx::Error&gt; {\n    let pool = PgPool::connect(&amp;std::env::var(\"DATABASE_URL\").unwrap()).await?;\n    sqlx::query(\"CREATE EXTENSION IF NOT EXISTS vector\").execute(&amp;pool).await?;\n    sqlx::query(\"DROP TABLE IF EXISTS items\").execute(&amp;pool).await?;\n    sqlx::query(\"CREATE TABLE items (id bigserial PRIMARY KEY, embedding vector(128) NOT NULL)\").execute(&amp;pool).await?;\n\n    let mut rng = Rng(42);\n    let centers: Vec&lt;Vec&lt;f32&gt;&gt; = (0..CLUSTERS).map(|_| normalise((0..DIMS).map(|_| rng.next()).collect())).collect();\n\n    let t = Instant::now();\n    for _ in 0..N / 1000 {\n        let mut qb: QueryBuilder&lt;Postgres&gt; = QueryBuilder::new(\"INSERT INTO items (embedding) \");\n        qb.push_values(0..1000, |mut b, _| {\n            b.push_bind(Vector::from(point(&amp;mut rng, &amp;centers)));\n        });\n        qb.build().execute(&amp;pool).await?;\n    }\n    println!(\"inserted {N} vectors of {DIMS} dimensions in {:.1} s\", t.elapsed().as_secs_f64());\n    sqlx::query(\"ANALYZE items\").execute(&amp;pool).await?;\n\n    let queries: Vec&lt;Vector&gt; = (0..30).map(|_| Vector::from(point(&amp;mut rng, &amp;centers))).collect();\n\n    let t = Instant::now();\n    let mut exact = Vec::new();\n    for q in &amp;queries {\n        exact.push(top10(&amp;pool, q).await?);\n    }\n    let seq = t.elapsed().as_secs_f64() * 1000.0 / queries.len() as f64;\n    println!(\"exact (sequential scan): {seq:.1} ms per query\");\n\n    let t = Instant::now();\n    sqlx::query(\"CREATE INDEX items_hnsw ON items USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64)\").execute(&amp;pool).await?;\n    println!(\"HNSW index built in {:.1} s\", t.elapsed().as_secs_f64());\n    let size: i64 = sqlx::query_scalar(\"SELECT pg_relation_size('items_hnsw')\").fetch_one(&amp;pool).await?;\n    let table: i64 = sqlx::query_scalar(\"SELECT pg_relation_size('items')\").fetch_one(&amp;pool).await?;\n    println!(\"table {:.0} MB, index {:.0} MB\", table as f64 / 1048576.0, size as f64 / 1048576.0);\n\n    for ef in [10, 40, 100, 400] {\n        let mut conn = pool.acquire().await?;\n        sqlx::query(AssertSqlSafe(format!(\"SET hnsw.ef_search = {ef}\"))).execute(&amp;mut *conn).await?;\n        let t = Instant::now();\n        let mut hits = 0;\n        for (q, truth) in queries.iter().zip(&amp;exact) {\n            let rows = sqlx::query(\"SELECT id FROM items ORDER BY embedding &lt;=&gt; $1 LIMIT 10\").bind(q).fetch_all(&amp;mut *conn).await?;\n            hits += rows.iter().filter(|r| truth.contains(&amp;r.get::&lt;i64, _&gt;(0))).count();\n        }\n        let ms = t.elapsed().as_secs_f64() * 1000.0 / queries.len() as f64;\n        println!(\"hnsw.ef_search = {ef:&gt;3}: {ms:&gt;5.2} ms per query, recall@10 = {:.1}%\", hits as f64 * 100.0 / (queries.len() * 10) as f64);\n    }\n    Ok(())\n}\n</code></pre>\n<pre><code class=\"language-text\">inserted 100000 vectors of 128 dimensions in 0.7 s\nexact (sequential scan): 12.8 ms per query\nHNSW index built in 35.4 s\ntable 56 MB, index 79 MB\nhnsw.ef_search =  10:  0.85 ms per query, recall@10 = 39.3%\nhnsw.ef_search =  40:  1.05 ms per query, recall@10 = 67.3%\nhnsw.ef_search = 100:  1.58 ms per query, recall@10 = 78.7%\nhnsw.ef_search = 400:  4.74 ms per query, recall@10 = 91.7%</code></pre>\n<p>Exact search over 100,000 vectors took 12.8 ms per query. That is perfectly acceptable for a small table and does not scale: ten million vectors would be about 1.3 seconds. The remedy is an approximate nearest neighbour index, and the numbers show the cost of approximation honestly, which most introductions leave out. The HNSW index (hierarchical navigable small world, a layered proximity graph) answered in under a millisecond to 4.7 ms depending on one setting, <code>hnsw.ef_search</code>, the size of the candidate list kept during the graph walk. At 10 it was 15 times faster than the scan and found only 39 percent of the true ten nearest neighbours; at 400 it was 2.7 times faster and found 92 percent. This is <em>recall</em>, the fraction of the true top ten that the index returned, measured against the exact answers computed first. No setting reaches 100 percent for free. Choose the recall your product needs (a \"related posts\" widget tolerates 70 percent; a retrieval step feeding an answer-generation model usually wants 90 or more), measure it on your own data, and set <code>ef_search</code> to the cheapest value that meets it. The index also costs to build and store: 35 seconds and 79 MB here, larger than the 56 MB table, so budget memory for it (a vector index is fast when it fits in cache).</p>\n<h2>The two build parameters</h2>\n<p>The index in the benchmark was created with:</p>\n<pre><code class=\"language-sql\">CREATE INDEX items_hnsw ON items USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);</code></pre>\n<p>The operator class (<code>vector_cosine_ops</code>) must match the operator in your queries: an index built for cosine distance is not used for <code>&lt;-&gt;</code>. <code>m</code> is the number of connections per node in the graph (16 is the default) and <code>ef_construction</code> is the candidate list size while building (64 is the default); raising either improves recall at a given <code>ef_search</code> and costs build time and index size. They are fixed at build time, while <code>ef_search</code> is a per-session setting you can change per query, which is why tuning starts there. pgvector also offers IVFFlat indexes, which cluster the vectors into lists and search the nearest few lists: they build faster and use less memory, and generally give lower recall at the same speed; they must be built after the table has data, since the clusters come from it. HNSW is the usual default.</p>\n<h2>The trap: filtered search that returns nothing</h2>\n<p>Real queries have a filter: nearest documents <em>in this category</em>, <em>for this tenant</em>. With an approximate index, the filter is applied after the index returns its candidates, and that interacts badly with selective filters. On the same table, with a category column that splits the rows into 100 groups of 1,000, a search for the ten nearest items in one category with <code>ef_search = 40</code>:</p>\n<pre><code class=\"language-text\">-- filtered search, default settings\n count\n-------\n     0\n\n-- same with iterative scan\n count\n-------\n    10</code></pre>\n<p>The default returned zero rows. The index produced its 40 nearest neighbours of the query vector across the whole table, and none of them happened to be in the requested category, so the filter discarded all of them. Nothing failed; the query simply answered \"no results\". The fix in pgvector 0.8 and newer is iterative index scans: <code>SET hnsw.iterative_scan = relaxed_order;</code> tells the scan to keep walking the graph until it has enough rows that pass the filter (or reaches a limit), and the same query returned 10. The mode name states the trade-off: results are close to, but not strictly in, distance order, and you can re-sort the final ten in SQL. Other remedies when the filter is very selective: a partial index per tenant, a table partition per tenant, or an exact search when the filter leaves few rows (the planner should choose it if statistics are fresh, so run <code>ANALYZE</code>). The worst habit is to test with an unfiltered query and ship a filtered one. Add a test that asserts the filtered query returns the requested number of rows for a selective filter.</p>\n<h2>Check that the index is used</h2>\n<pre><code class=\"language-text\">Limit\n  InitPlan 1\n    -&gt;  Index Scan using items_pkey on items items_1\n          Index Cond: (id = 5000)\n  -&gt;  Index Scan using items_hnsw on items\n        Order By: (embedding &lt;=&gt; (InitPlan 1).col1)</code></pre>\n<p>That is the plan to look for in <code>EXPLAIN</code>: an <code>Index Scan using items_hnsw</code> with an <code>Order By</code> on the distance expression. If you see <code>Seq Scan</code> and a <code>Sort</code>, the index is not used. The usual causes are a mismatched operator class, a missing <code>LIMIT</code> (the index serves ordered top-k queries, not unbounded ones), a cast that changes the type, or stale statistics on a small table where the planner correctly judges the scan cheaper. Reading plans is the subject of <a href=\"/library/rust-postgres-explain-indexes\">PostgreSQL EXPLAIN and indexes</a>.</p>\n<h2>Putting the model behind a trait</h2>\n<p>The toy <code>embed</code> function is the seam where the real model goes, and the way to keep that seam clean is a small trait that also enforces the two invariants that matter when you swap models: batch sizes match, and the dimensions equal the column's. A real implementation of this trait would make an HTTP request to an embedding service (or run a local model) in <code>embed</code>; the pipeline around it does not change.</p>\n<pre data-filename=\"src/embed.rs\"><code class=\"language-rust\">use anyhow::{bail, Result};\n\ntrait Embedder {\n    fn model(&amp;self) -&gt; &amp;str;\n    fn dimensions(&amp;self) -&gt; usize;\n    async fn embed(&amp;self, texts: &amp;[String]) -&gt; Result&lt;Vec&lt;Vec&lt;f32&gt;&gt;&gt;;\n}\n\nstruct HashEmbedder;\n\nimpl Embedder for HashEmbedder {\n    fn model(&amp;self) -&gt; &amp;str {\n        \"toy-hash-64\"\n    }\n\n    fn dimensions(&amp;self) -&gt; usize {\n        64\n    }\n\n    async fn embed(&amp;self, texts: &amp;[String]) -&gt; Result&lt;Vec&lt;Vec&lt;f32&gt;&gt;&gt; {\n        Ok(texts\n            .iter()\n            .map(|t| {\n                let mut v = vec![0f32; 64];\n                for w in t.split_whitespace() {\n                    let h = w.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));\n                    v[(h % 64) as usize] += 1.0;\n                }\n                let n = v.iter().map(|x| x * x).sum::&lt;f32&gt;().sqrt().max(1e-9);\n                v.iter().map(|x| x / n).collect()\n            })\n            .collect())\n    }\n}\n\nstruct Row {\n    body: String,\n    model: String,\n    vector: Vec&lt;f32&gt;,\n}\n\nasync fn embed_all&lt;E: Embedder&gt;(embedder: &amp;E, texts: Vec&lt;String&gt;, batch: usize) -&gt; Result&lt;Vec&lt;Row&gt;&gt; {\n    let mut rows = Vec::with_capacity(texts.len());\n    for chunk in texts.chunks(batch) {\n        let vectors = embedder.embed(chunk).await?;\n        if vectors.len() != chunk.len() {\n            bail!(\"model returned {} vectors for {} inputs\", vectors.len(), chunk.len());\n        }\n        for (body, vector) in chunk.iter().zip(vectors) {\n            if vector.len() != embedder.dimensions() {\n                bail!(\"expected {} dimensions, got {}\", embedder.dimensions(), vector.len());\n            }\n            rows.push(Row { body: body.clone(), model: embedder.model().to_string(), vector });\n        }\n    }\n    Ok(rows)\n}\n\n#[tokio::main]\nasync fn main() -&gt; Result&lt;()&gt; {\n    let texts: Vec&lt;String&gt; = (0..150).map(|i| format!(\"document number {i} about rust\")).collect();\n    let rows = embed_all(&amp;HashEmbedder, texts, 64).await?;\n    println!(\"{} rows, model {}, {} dimensions, first body {:?}\", rows.len(), rows[0].model, rows[0].vector.len(), rows[0].body);\n    Ok(())\n}\n</code></pre>\n<pre><code class=\"language-text\">150 rows, model toy-hash-64, 64 dimensions, first body \"document number 0 about rust\"</code></pre>\n<p>Each row carries the model name, which pays off the first time you change models. The checks in <code>embed_all</code> catch the failures that otherwise surface as a confusing database error (\"expected 1536 dimensions, not 768\") or, worse, silently mismatched results: a service that returns fewer vectors than inputs when a text is dropped, or a model whose dimension was changed in a new release. Batch the calls (here 64 texts at a time), since embedding services charge and rate-limit per request, and wrap the call with the retry and timeout patterns from <a href=\"/library/rust-async-resilience-patterns\">async resilience patterns</a>, since a bulk re-embedding job runs for hours and will meet transient failures.</p>\n<h2>Writes are expensive once the index exists</h2>\n<p>HNSW makes reads cheap by doing work at write time: each insert has to find its place in the graph. The same 10,000 vectors that loaded in a fraction of a second into the unindexed table took 8.8 seconds to insert into the table that already had the 79 MB index, about 0.88 ms per row against roughly 7 microseconds without it, a factor of 125. Deletes are cheap at the moment and leave dead entries until <code>VACUUM</code> (about 180 ms for this table after deleting 10,000 rows). Three rules follow. For a bulk load, insert first and build the index afterwards (35 seconds for 100,000 rows here, for a table you then query in a millisecond). For a steady trickle of writes, accept the per-row cost, which is small in absolute terms, and batch inserts in a transaction. For a re-embedding migration that touches every row, build a second column or table with the new vectors, index it, and switch queries over, instead of updating in place under a live index.</p>\n<h2>Operating it</h2>\n<ul>\n<li><strong>Store the model name with the vector.</strong> Embeddings from two models are not comparable. A column <code>embedding_model text</code> lets you re-embed gradually and query only vectors from the current model.</li>\n<li><strong>Normalise at write time</strong> if you use the inner-product operator, and keep the dimension a property of the column, not of the code.</li>\n<li><strong>Chunk long text.</strong> Models have input limits and one vector per document averages away the details. A few hundred words per chunk, with the chunk text and its parent id stored beside the vector, is the common arrangement.</li>\n<li><strong>Know the dimension limits.</strong> The <code>vector</code> type holds many thousands of dimensions, but indexes are limited to about 2,000; the <code>halfvec</code> type (16-bit floats) halves the storage and indexes up to about 4,000. Check the extension's documentation for the exact figures in your version.</li>\n<li><strong>Give the build memory.</strong> Set <code>maintenance_work_mem</code> large enough for the graph during <code>CREATE INDEX</code>, or the build slows sharply once it spills; building the index before loading data is much slower than loading, then indexing.</li>\n<li><strong>Combine with ordinary search.</strong> Vector search finds meaning and misses exact terms (names, error codes). Hybrid designs run a full-text query and a vector query, then merge the rankings, which is often better than either alone.</li>\n</ul>\n<h2>Estimating storage before you commit</h2>\n<p>A <code>vector(n)</code> value takes 4 bytes per dimension plus an 8 byte header, so 128 dimensions is 520 bytes, and 100,000 of them is 52 million bytes: the benchmark table measured 56 MB once page overhead and the bigserial key are included. Scale it for your case: a million 1,536-dimension vectors is about 6.1 GB of table, and an HNSW index on them can be larger than that again, so the working set that must fit in memory for fast queries is a multiple of the raw data. Do this arithmetic first. If it does not fit, the options are fewer dimensions (some models can be truncated), <code>halfvec</code> storage at half the size, or a different store. An hour of arithmetic here is cheaper than discovering at launch that the index no longer fits in RAM and every query is a disk read.</p>\n<h2>When pgvector is not the right store</h2>\n<p>Use it when your data is already in PostgreSQL, the table has up to a few million vectors and you value transactions, joins and one system to operate. Reach for a dedicated vector database when you have hundreds of millions of vectors, very high query rates with tight latency, or need features such as built-in sharding for vectors. Skip vectors entirely when keyword search already answers the question; PostgreSQL full-text search and trigram indexes are cheaper to run and easier to explain (see <a href=\"/library/rust-full-text-search\">full-text search from Rust</a>). The SQLx patterns used here, pooling, transactions and the compile-time macros, are covered in <a href=\"/library/sqlx-explained\">SQLx explained</a>, and the broader cache-and-index thinking behind choosing a store is in <a href=\"/library/rust-postgres-vs-sqlite-choice\">PostgreSQL versus SQLite</a>.</p>","date_published":"2026-11-03T10:00:00+00:00","tags":["Databases"]},{"id":"https://rusthacks.dev/library/node-addons-napi-rs","url":"https://rusthacks.dev/library/node-addons-napi-rs","title":"Node Addons in Rust With Napi rs for JavaScript Speed","summary":"napi-rs generates a Node-API addon from annotated Rust. Functions become JavaScript functions, async work runs on a thread pool, and TypeScript types come for free.","content_html":"<p>Node.js is excellent at I/O and a poor place to run a CPU-heavy loop: one thread executes your JavaScript, so a 250 ms computation freezes every request on the process for 250 ms. A native addon written in Rust fixes the freezing, not necessarily the speed, and it is worth being clear about that difference before writing any code. With napi-rs you write ordinary Rust functions, annotate them with <code>#[napi]</code>, and get a <code>.node</code> binary that <code>require()</code> loads, with TypeScript declarations generated for you. This guide builds an addon from an empty directory with a synchronous function, a function returning structured data, a function that runs on Node's thread pool and returns a promise, an <code>async fn</code>, and a callback. It measures what the addon is good for (keeping the event loop free, and using several cores) and what it is not (beating V8 at string work), and it records two conversion traps that silently produce wrong answers. Everything ran on Node 24.1.0 with Rust 1.99, napi 3.16, napi-derive 3.7, napi-build 2.6 and the <code>@napi-rs/cli</code> 3.x tool, on an Apple M1 Max.</p>\n<h2>What N-API is, and why napi-rs</h2>\n<p>Node loads native code through N-API, a stable C interface for addons: functions to read arguments, create strings and objects, throw errors and schedule work, with a promise that binaries compiled against one version keep working on later Node versions. Writing against it by hand in C is tedious and unsafe. <code>napi-rs</code> wraps the interface: the <code>#[napi]</code> macro generates the glue that converts JavaScript values to Rust types and back, the <code>napi-build</code> crate configures the linker, and the <code>@napi-rs/cli</code> npm package drives the build, names the output file for the platform and generates the TypeScript declarations and the loader script. Because the interface is stable, you build one binary per operating system and CPU, not one per Node version.</p>\n<h2>The project</h2>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[package]\nname = \"fastnode\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\nnapi = { version = \"3\", default-features = false, features = [\"napi6\", \"async\", \"tokio_rt\"] }\nnapi-derive = \"3\"\ntokio = { version = \"1\", features = [\"time\"] }\n\n[build-dependencies]\nnapi-build = \"2\"\n\n[profile.release]\nlto = true\n</code></pre>\n<pre data-filename=\"build.rs\"><code class=\"language-rust\">fn main() {\n    napi_build::setup();\n}\n</code></pre>\n<pre data-filename=\"package.json\"><code class=\"language-json\">{\n  \"name\": \"fastnode\",\n  \"version\": \"0.1.0\",\n  \"main\": \"index.js\",\n  \"types\": \"index.d.ts\",\n  \"napi\": {\n    \"binaryName\": \"fastnode\",\n    \"targets\": [\"aarch64-apple-darwin\"]\n  },\n  \"scripts\": {\n    \"build\": \"napi build --platform --release\"\n  },\n  \"devDependencies\": {\n    \"@napi-rs/cli\": \"^3.0.0\"\n  }\n}\n</code></pre>\n<p>The crate type <code>cdylib</code> makes a dynamic library, which the CLI renames to <code>fastnode.darwin-arm64.node</code>, because Node loads files with the <code>.node</code> extension. The <code>napi6</code> feature selects the N-API version you require (version 6 is old enough to be available on every supported Node), <code>async</code> enables the thread-pool task API and <code>tokio_rt</code> lets <code>async fn</code> exports run on a Tokio runtime that napi-rs manages; a direct <code>tokio</code> dependency with the <code>time</code> feature is still needed because your own code names <code>tokio::time::sleep</code>. <code>default-features = false</code> keeps the binary small. The <code>build.rs</code> is two lines and mandatory: <code>napi_build::setup()</code> passes the platform-specific linker flags (on macOS, the undefined-symbol flags that let the addon resolve N-API functions from the Node process that loads it). In <code>package.json</code>, <code>napi.binaryName</code> sets the output name and <code>targets</code> lists the platforms the project builds for, and the one script, <code>napi build --platform --release</code>, is the whole build. Run <code>npm install</code> once to fetch the CLI.</p>\n<h2>The Rust side</h2>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">use napi::bindgen_prelude::*;\nuse napi_derive::napi;\nuse std::collections::HashMap;\n\n/// Iterative Fibonacci, wrapping like a u64.\n#[napi]\npub fn fib(n: u32) -&gt; BigInt {\n    let (mut a, mut b) = (0u64, 1u64);\n    for _ in 0..n {\n        (a, b) = (b, a.wrapping_add(b));\n    }\n    BigInt::from(a)\n}\n\n#[napi(object)]\npub struct WordCount {\n    pub word: String,\n    pub count: u32,\n}\n\n#[napi]\npub fn top_words(text: String, n: u32) -&gt; Result&lt;Vec&lt;WordCount&gt;&gt; {\n    if n == 0 {\n        return Err(Error::new(Status::InvalidArg, \"n must be at least 1\"));\n    }\n    let mut counts: HashMap&lt;String, u32&gt; = HashMap::new();\n    for w in text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {\n        *counts.entry(w.to_lowercase()).or_default() += 1;\n    }\n    let mut pairs: Vec&lt;_&gt; = counts.into_iter().collect();\n    pairs.sort_by(|a, b| b.1.cmp(&amp;a.1).then_with(|| a.0.cmp(&amp;b.0)));\n    pairs.truncate(n as usize);\n    Ok(pairs.into_iter().map(|(word, count)| WordCount { word, count }).collect())\n}\n\npub struct Churn {\n    values: Vec&lt;u32&gt;,\n    rounds: u32,\n}\n\n#[napi]\nimpl Task for Churn {\n    type Output = u32;\n    type JsValue = u32;\n\n    fn compute(&amp;mut self) -&gt; Result&lt;Self::Output&gt; {\n        Ok(self.values.iter().fold(0u32, |acc, v| {\n            let mut h = *v ^ 0x811c9dc5;\n            for _ in 0..self.rounds {\n                h = (h ^ (h &gt;&gt; 15)).wrapping_mul(0x01000193);\n            }\n            acc ^ h\n        }))\n    }\n\n    fn resolve(&amp;mut self, _env: Env, output: Self::Output) -&gt; Result&lt;Self::JsValue&gt; {\n        Ok(output)\n    }\n}\n\n#[napi(ts_return_type = \"Promise&lt;number&gt;\")]\npub fn churn(values: Vec&lt;u32&gt;, rounds: u32) -&gt; AsyncTask&lt;Churn&gt; {\n    AsyncTask::new(Churn { values, rounds })\n}\n\n#[napi]\npub async fn delayed_double(n: u32, ms: u32) -&gt; Result&lt;u32&gt; {\n    tokio::time::sleep(std::time::Duration::from_millis(u64::from(ms))).await;\n    n.checked_mul(2).ok_or_else(|| Error::new(Status::GenericFailure, format!(\"{n} * 2 overflows u32\")))\n}\n\n#[napi]\npub fn for_each_line(text: String, callback: Function&lt;'_, FnArgs&lt;(u32, String)&gt;, ()&gt;) -&gt; Result&lt;u32&gt; {\n    let mut n = 0;\n    for (i, line) in text.lines().enumerate() {\n        callback.call(FnArgs::from((i as u32, line.to_string())))?;\n        n += 1;\n    }\n    Ok(n)\n}\n</code></pre>\n<p>Each export shows a different idea. <code>fib</code> returns <code>BigInt</code> because a JavaScript <code>number</code> cannot hold every 64-bit integer: above 2 to the power of 53 it loses precision silently, and <code>BigInt</code> is the faithful type. <code>#[napi(object)]</code> on a struct makes a plain JavaScript object with typed fields, which <code>top_words</code> returns as an array. Returning <code>Result&lt;T&gt;</code> turns an <code>Err</code> into a thrown exception whose <code>code</code> is the <code>Status</code> you chose. The <code>Churn</code> struct implements <code>Task</code>: <code>compute</code> runs on a worker thread from libuv's pool (never on the JavaScript thread), and <code>resolve</code> runs back on the main thread to turn the result into a JavaScript value; wrapping it in <code>AsyncTask</code> makes the exported function return a promise. <code>delayed_double</code> is an ordinary <code>async fn</code>, run on the managed Tokio runtime. <code>for_each_line</code> receives a JavaScript function and calls it synchronously for each line.</p>\n<h2>Build and look at what was generated</h2>\n<pre><code class=\"language-bash\">npm run build</code></pre>\n<p>The release build took 12 seconds the first time and produced a 786 KB <code>fastnode.darwin-arm64.node</code> (the size includes the Tokio pieces), plus two generated files you commit: <code>index.js</code>, an 840-line loader that picks the right binary for the current platform and falls back with a clear error, and <code>index.d.ts</code>, the declarations. The declarations are the payoff for the annotations; this is the generated content for the exports:</p>\n<pre data-filename=\"index.d.ts\"><code class=\"language-typescript\">export declare function churn(values: Array&lt;number&gt;, rounds: number): Promise&lt;number&gt;\n\nexport declare function delayedDouble(n: number, ms: number): Promise&lt;number&gt;\n\n/** Iterative Fibonacci, wrapping like a u64. */\nexport declare function fib(n: number): bigint\n\nexport declare function forEachLine(text: string, callback: (arg0: number, arg1: string) =&gt; void): number\n\nexport declare function topWords(text: string, n: number): Array&lt;WordCount&gt;\n\nexport interface WordCount {\n  word: string\n  count: number\n}\n</code></pre>\n<p>Rust names are converted to camelCase (<code>top_words</code> became <code>topWords</code>), doc comments become JSDoc, the struct became an <code>interface</code>, and the tuple callback parameters became two positional arguments. That last one deserves a note: the first version of <code>for_each_line</code> declared its callback as <code>Function&lt;'_, (u32, String), ()&gt;</code>, and the generated type was <code>(arg: [number, string]) =&gt; void</code>. The callback was called with one array argument, so a JavaScript function written as <code>(i, line) =&gt; ...</code> received the array in <code>i</code> and <code>undefined</code> in <code>line</code>, and the output read <code>0,alpha:undefined</code>. Wrapping the argument type in <code>FnArgs&lt;(u32, String)&gt;</code>, as the final source does, spreads the tuple into positional arguments. Read the generated <code>.d.ts</code> after every signature change; it is the contract JavaScript sees.</p>\n<h2>Call it from JavaScript</h2>\n<pre data-filename=\"demo.js\"><code class=\"language-javascript\">const { fib, topWords, churn } = require('./index.js');\n\nconsole.log(fib(50), typeof fib(50));\nconsole.log(topWords('the cat and the hat and THE bat', 2));\ntry {\n  topWords('x', 0);\n} catch (e) {\n  console.log(e.code, '-', e.message);\n}\ntry {\n  fib(-1);\n} catch (e) {\n  console.log(e.code, '-', e.message);\n}\nchurn([1, 2, 3], 10).then((v) =&gt; console.log('churn resolved', v));\nconsole.log('this line prints before the promise resolves');\n</code></pre>\n<pre><code class=\"language-text\">12586269025n bigint\n[ { word: 'the', count: 3 }, { word: 'and', count: 2 } ]\nInvalidArg - n must be at least 1\nthis line prints before the promise resolves\nchurn resolved 806291124</code></pre>\n<p>The results are what the signatures promised: <code>fib(50)</code> is a <code>bigint</code>, <code>topWords</code> returns real objects, a bad argument throws with the code and message from Rust, and the promise resolves after the synchronous line has already printed, proving that <code>churn</code> ran off the main thread. The remaining exports behave the same way:</p>\n<pre data-filename=\"async_demo.js\"><code class=\"language-javascript\">const { delayedDouble, forEachLine } = require('./index.js');\n\n(async () =&gt; {\n  const t = Date.now();\n  const [a, b] = await Promise.all([delayedDouble(21, 100), delayedDouble(5, 100)]);\n  console.log(a, b, 'in about', Math.round((Date.now() - t) / 100) * 100, 'ms');\n  try {\n    await delayedDouble(3_000_000_000, 1);\n  } catch (e) {\n    console.log(e.code, '-', e.message);\n  }\n  const seen = [];\n  const count = forEachLine('alpha\\nbeta\\ngamma', (i, line) =&gt; seen.push(`${i}:${line}`));\n  console.log(count, seen);\n})();\n</code></pre>\n<pre><code class=\"language-text\">42 10 in about 100 ms\nGenericFailure - 3000000000 * 2 overflows u32\n3 [ '0:alpha', '1:beta', '2:gamma' ]</code></pre>\n<p>Two concurrent calls with a 100 ms Rust-side sleep completed in about 100 ms in total, not 200, because the async function is truly asynchronous. The overflow error arrived as a rejected promise with the code and message from Rust. And the callback saw positional arguments.</p>\n<h2>Two conversions that quietly give wrong answers</h2>\n<p>PyO3 raises <code>OverflowError</code> for a negative number passed to an unsigned parameter. N-API's number conversion does not. JavaScript has one numeric type, a double, and napi-rs converts it to <code>u32</code> by the engine's rules, which wrap:</p>\n<pre data-filename=\"probe.js\"><code class=\"language-javascript\">const { fib } = require('./index.js');\nlet t = process.hrtime.bigint();\nconst r = fib(-1);\nconsole.log('fib(-1) =', r, 'in', Number(process.hrtime.bigint() - t) / 1e6, 'ms');\ntry { fib(1.5); console.log('fib(1.5) ok'); } catch (e) { console.log('fib(1.5):', e.code, e.message); }\ntry { fib('7'); console.log('fib(\"7\") ok'); } catch (e) { console.log('fib(\"7\"):', e.code, e.message); }\ntry { fib(2 ** 40); console.log('fib(2**40) ok'); } catch (e) { console.log('fib(2**40):', e.code, e.message); }\n</code></pre>\n<pre><code class=\"language-text\">fib(-1) = 6337557995341940450n in 1387.272208 ms\nfib(1.5) ok\nfib(\"7\"): NumberExpected Failed to convert napi value String into rust type `u32`\nfib(2**40) ok</code></pre>\n<p><code>fib(-1)</code> did not throw. The value became 4,294,967,295, and the function ran four billion iterations for 1.39 seconds, blocking the event loop for the whole time, then returned a plausible-looking number. <code>fib(1.5)</code> truncated to 1, <code>fib(2**40)</code> wrapped to 0, and only a string was rejected. If a function takes an unsigned or bounded integer from untrusted JavaScript, accept <code>i64</code> or <code>f64</code> and validate the range yourself (<code>if !(0..=1000).contains(&amp;n) { return Err(...) }</code>), or use <code>BigInt</code> for exact 64-bit values. A request handler that passes <code>req.query.n</code> to a Rust function would otherwise hand any client a denial-of-service switch.</p>\n<h2>What it is good for, measured</h2>\n<pre data-filename=\"bench.js\"><code class=\"language-javascript\">const { topWords, churn } = require('./index.js');\n\nfunction jsTopWords(text, n) {\n  const counts = new Map();\n  for (const w of text.split(/[^\\p{L}\\p{N}]+/u)) {\n    if (!w) continue;\n    const k = w.toLowerCase();\n    counts.set(k, (counts.get(k) || 0) + 1);\n  }\n  return [...counts.entries()]\n    .sort((a, b) =&gt; b[1] - a[1] || (a[0] &lt; b[0] ? -1 : 1))\n    .slice(0, n)\n    .map(([word, count]) =&gt; ({ word, count }));\n}\n\nfunction jsChurn(values, rounds) {\n  let acc = 0;\n  for (const v of values) {\n    let h = (v ^ 0x811c9dc5) &gt;&gt;&gt; 0;\n    for (let i = 0; i &lt; rounds; i++) {\n      h = Math.imul((h ^ (h &gt;&gt;&gt; 15)) &gt;&gt;&gt; 0, 0x01000193) &gt;&gt;&gt; 0;\n    }\n    acc = (acc ^ h) &gt;&gt;&gt; 0;\n  }\n  return acc;\n}\n\nfunction best(f, runs = 5) {\n  let b = Infinity;\n  for (let i = 0; i &lt; runs; i++) {\n    const t = process.hrtime.bigint();\n    f();\n    b = Math.min(b, Number(process.hrtime.bigint() - t) / 1e6);\n  }\n  return b;\n}\n\nasync function main() {\n  const text = 'the quick brown fox jumps over the lazy dog and the cat '.repeat(20000);\n  const js = best(() =&gt; jsTopWords(text, 3));\n  const rs = best(() =&gt; topWords(text, 3));\n  console.log(`topWords on ${(text.length / 1e6).toFixed(1)} MB: js ${js.toFixed(1)} ms | rust ${rs.toFixed(1)} ms | ${(js / rs).toFixed(1)}x`);\n  console.log(JSON.stringify(jsTopWords(text, 3)) === JSON.stringify(topWords(text, 3)) ? 'same answer' : 'DIFFERENT');\n\n  const values = Array.from({ length: 2000 }, (_, i) =&gt; i);\n  const rounds = 20000;\n  if (jsChurn(values, rounds) !== (await churn(values, rounds))) throw new Error('churn mismatch');\n\n  let t = process.hrtime.bigint();\n  for (let i = 0; i &lt; 4; i++) jsChurn(values, rounds);\n  const jsSeq = Number(process.hrtime.bigint() - t) / 1e6;\n\n  t = process.hrtime.bigint();\n  await churn(values, rounds);\n  const rsOne = Number(process.hrtime.bigint() - t) / 1e6;\n\n  t = process.hrtime.bigint();\n  await Promise.all([1, 2, 3, 4].map(() =&gt; churn(values, rounds)));\n  const rsFour = Number(process.hrtime.bigint() - t) / 1e6;\n  console.log(`churn: js x4 on the main thread ${jsSeq.toFixed(0)} ms | rust x1 ${rsOne.toFixed(0)} ms | rust x4 in parallel ${rsFour.toFixed(0)} ms`);\n\n  // event loop lag while the work runs\n  async function lag(work) {\n    let worst = 0, last = performance.now();\n    const timer = setInterval(() =&gt; {\n      const now = performance.now();\n      worst = Math.max(worst, now - last - 10);\n      last = now;\n    }, 10);\n    await new Promise((r) =&gt; setTimeout(r, 30));\n    await work();\n    await new Promise((r) =&gt; setTimeout(r, 30));\n    clearInterval(timer);\n    return worst;\n  }\n  const lagJs = await lag(async () =&gt; { for (let i = 0; i &lt; 4; i++) jsChurn(values, rounds); });\n  const lagRs = await lag(() =&gt; Promise.all([1, 2, 3, 4].map(() =&gt; churn(values, rounds))));\n  console.log(`worst timer delay while working: js ${lagJs.toFixed(0)} ms | rust AsyncTask ${lagRs.toFixed(0)} ms`);\n}\nmain();\n</code></pre>\n<pre><code class=\"language-text\">topWords on 1.1 MB: js 17.5 ms | rust 11.9 ms | 1.5x\nsame answer\nchurn: js x4 on the main thread 258 ms | rust x1 64 ms | rust x4 in parallel 66 ms\nworst timer delay while working: js 258 ms | rust AsyncTask 2 ms</code></pre>\n<p>Read it honestly. <strong>Raw speed on string work is modest:</strong> counting words in 1.1 MB took 17.5 ms in V8 and 11.9 ms in Rust, a 1.5x difference. V8's string and map code is very good, and it takes a lot of Rust to beat. <strong>A tight numeric loop is a tie:</strong> the JavaScript version of the hash loop ran at the same speed as the Rust one (about 64 ms per pass), because V8 compiles integer loops with <code>Math.imul</code> to near-native code. If a profile says your slow function is a loop like this, an addon will not make a single call faster. <strong>What the addon changes is where the work runs.</strong> Four passes in JavaScript took 258 ms on the main thread, during which a 10 ms timer was delayed by up to 258 ms, which is a stalled server. The four Rust tasks ran on the thread pool in parallel and finished in 66 ms, and the worst timer delay was 2 ms: the event loop stayed responsive and the work used four cores. For comparison, Node's own answer to the same problem is <code>worker_threads</code>, which can run the same JavaScript in parallel without any Rust; the addon wins when the code is faster in Rust, when you want to share memory without copying, or when you need a Rust library that has no JavaScript equivalent.</p>\n<h2>Binary data, classes and calls from other threads</h2>\n<p>Three more exports cover the rest of what real addons need. <strong>Buffers:</strong> a <code>Uint8Array</code> parameter is a view onto the memory of a Node <code>Buffer</code>, not a copy, so passing a 100 MB buffer costs nothing and the function reads it in place. <strong>Classes:</strong> <code>#[napi]</code> on a struct and its <code>impl</code> produces a JavaScript class whose instances own a Rust value; <code>#[napi(constructor)]</code> is the constructor (an <code>Option&lt;u32&gt;</code> parameter makes an optional argument) and <code>#[napi(getter)]</code> makes a property. <strong>Threadsafe functions:</strong> JavaScript functions may only be called on the main thread, so a Rust thread that wants to report progress wraps the callback in a <code>ThreadsafeFunction</code>, which queues the call and lets Node run it on the event loop.</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">#[napi]\npub fn checksum(data: Uint8Array) -&gt; BigInt {\n    let h = data.iter().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3));\n    BigInt::from(h)\n}\n\n#[napi]\npub struct Counter {\n    count: u32,\n    step: u32,\n}\n\n#[napi]\nimpl Counter {\n    #[napi(constructor)]\n    pub fn new(step: Option&lt;u32&gt;) -&gt; Result&lt;Self&gt; {\n        let step = step.unwrap_or(1);\n        if step == 0 {\n            return Err(Error::new(Status::InvalidArg, \"step must be non-zero\"));\n        }\n        Ok(Counter { count: 0, step })\n    }\n\n    #[napi]\n    pub fn bump(&amp;mut self) -&gt; u32 {\n        self.count += self.step;\n        self.count\n    }\n\n    #[napi(getter)]\n    pub fn count(&amp;self) -&gt; u32 {\n        self.count\n    }\n}\n\n#[napi]\npub fn count_to(n: u32, on_tick: ThreadsafeFunction&lt;u32&gt;) {\n    std::thread::spawn(move || {\n        for i in 1..=n {\n            on_tick.call(Ok(i), ThreadsafeFunctionCallMode::NonBlocking);\n            std::thread::sleep(std::time::Duration::from_millis(20));\n        }\n    });\n}\n</code></pre>\n<pre data-filename=\"more_demo.js\"><code class=\"language-javascript\">const { checksum, Counter, countTo } = require('./index.js');\n\nconst data = Buffer.alloc(100 * 1024 * 1024, 7);\nconst t = process.hrtime.bigint();\nconst sum = checksum(data);\nconsole.log(`checksum of ${data.length / 1048576} MB buffer: ${sum.toString(16)} in ${Math.round(Number(process.hrtime.bigint() - t) / 1e6)} ms`);\n\nconst c = new Counter(5);\nconsole.log(c.bump(), c.bump(), c.count);\ntry { new Counter(0); } catch (e) { console.log(e.code, '-', e.message); }\n\nconst ticks = [];\nconst start = Date.now();\ncountTo(3, (err, i) =&gt; {\n  ticks.push(i);\n  if (i === 3) console.log('ticks', ticks, 'delivered over about', Math.round((Date.now() - start) / 10) * 10, 'ms');\n});\nconsole.log('countTo returned immediately');\n</code></pre>\n<pre><code class=\"language-text\">checksum of 100 MB buffer: 5f17c1a5b1622325 in 138 ms\n5 10 10\nInvalidArg - step must be non-zero\ncountTo returned immediately\nticks [ 1, 2, 3 ] delivered over about 50 ms</code></pre>\n<p>The checksum of a 100 MB buffer took 138 ms for a byte-at-a-time hash, with no copying, about 720 MB per second. The class kept its state in Rust between calls and threw the Rust-defined error from the constructor. <code>countTo</code> returned immediately, as the output order shows, and the three ticks arrived over about 40 ms from a background thread, each delivered as a normal callback on the event loop with the Node convention of an error first and the value second. The generated declarations mirror that: the callback is typed <code>(err: Error | null, arg: number) =&gt; any</code>. A <code>ThreadsafeFunction</code> keeps the process alive while it exists, which is right for a live subscription and wrong for a leaked one; call its <code>abort</code> method (or drop it in Rust once the thread finishes) so that Node can exit.</p>\n<p>The rules of thumb that fall out: use a <code>Buffer</code> or <code>Uint8Array</code> for anything large, because converting a JavaScript array of numbers to a <code>Vec</code> copies and converts every element; keep objects crossing the boundary small and flat, because every property read is a call into the engine; and do not hold JavaScript values on another thread, because they are tied to the thread and the realm that created them. If you need a result back from background work, return a promise (as <code>churn</code> does) instead of passing a callback, which composes with <code>async</code>/<code>await</code> and error handling.</p>\n<h2>Testing the addon</h2>\n<p>An addon is a native library with a JavaScript API, so test it the way you would test either: the Rust logic with <code>cargo test</code>, and the JavaScript surface with the runner you already use. Node's built-in <code>node:test</code> is enough and adds no dependency. The most useful tests compare the Rust result with a plain JavaScript reference over the edge cases, and check the properties that are the reason for the addon in the first place, such as keeping the event loop alive:</p>\n<pre data-filename=\"test.mjs\"><code class=\"language-javascript\">import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { createRequire } from 'node:module';\n\nconst { fib, topWords, churn, Counter } = createRequire(import.meta.url)('./index.js');\n\nfunction jsFib(n) {\n  let a = 0n, b = 1n;\n  const mask = (1n &lt;&lt; 64n) - 1n;\n  for (let i = 0; i &lt; n; i++) [a, b] = [b, (a + b) &amp; mask];\n  return a;\n}\n\ntest('fib matches a BigInt reference, including the 64-bit wrap', () =&gt; {\n  for (const n of [0, 1, 2, 10, 50, 93, 94, 1000]) {\n    assert.equal(fib(n), jsFib(n), `fib(${n})`);\n  }\n});\n\ntest('topWords sorts by count, then alphabetically', () =&gt; {\n  assert.deepEqual(topWords('b a b a c', 3), [\n    { word: 'a', count: 2 },\n    { word: 'b', count: 2 },\n    { word: 'c', count: 1 },\n  ]);\n});\n\ntest('topWords rejects n = 0 with InvalidArg', () =&gt; {\n  assert.throws(() =&gt; topWords('x', 0), { code: 'InvalidArg' });\n});\n\ntest('churn resolves off the main thread', async () =&gt; {\n  let ticked = false;\n  const timer = setTimeout(() =&gt; (ticked = true), 0);\n  const result = churn(Array.from({ length: 1000 }, (_, i) =&gt; i), 20000);\n  await new Promise((r) =&gt; setTimeout(r, 5));\n  assert.equal(ticked, true, 'the event loop kept running');\n  assert.equal(typeof (await result), 'number');\n  clearTimeout(timer);\n});\n\ntest('Counter keeps state in Rust', () =&gt; {\n  const c = new Counter(3);\n  c.bump();\n  assert.equal(c.bump(), 6);\n  assert.equal(c.count, 6);\n});\n</code></pre>\n<pre><code class=\"language-text\">✔ fib matches a BigInt reference, including the 64-bit wrap (1.138375ms)\n✔ topWords sorts by count, then alphabetically (0.551042ms)\n✔ topWords rejects n = 0 with InvalidArg (0.249291ms)\n✔ churn resolves off the main thread (32.71325ms)\n✔ Counter keeps state in Rust (0.149541ms)\nℹ tests 5\nℹ pass 5\nℹ fail 0</code></pre>\n<p>The first test pins the 64-bit wrap: the reference uses BigInt with a mask, and the case list includes 93 and 94, the boundary where a Fibonacci number stops fitting in 64 bits. The third test asserts the error <code>code</code>, not just that something threw, since the code is the part of the contract that JavaScript callers branch on. The fourth proves the property: while the Rust task ran, a zero-delay timer fired, so the event loop was not blocked. Put the build in the test script (<code>npm run build &amp;&amp; node --test</code>), and run the suite in CI against the built binary on every platform you ship, since a native addon can pass on Linux and fail to load on Windows.</p>\n<h2>Choosing between an addon, WebAssembly and worker threads</h2>\n<p>An addon is not the only way to move work off the JavaScript thread, and it is not always the best. The options differ in what they cost to build, what they can reach, and where they run:</p>\n<table>\n<thead><tr><th></th><th>Native addon (napi-rs)</th><th>WebAssembly</th><th><code>worker_threads</code></th></tr></thead>\n<tbody>\n<tr><td>Runs in</td><td>Node only</td><td>Node and browsers</td><td>Node only</td></tr>\n<tr><td>Speed</td><td>Native, with threads and SIMD</td><td>Close to native, sandboxed, no direct OS access</td><td>JavaScript speed, one thread each</td></tr>\n<tr><td>Can use system libraries, files, sockets</td><td>Yes</td><td>Only through the host</td><td>Yes</td></tr>\n<tr><td>Distribution</td><td>One binary per OS and CPU</td><td>One <code>.wasm</code> file</td><td>Plain JavaScript</td></tr>\n<tr><td>Failure mode</td><td>A crash takes down the process</td><td>A trap is an exception</td><td>A worker error is an event</td></tr>\n<tr><td>Best for</td><td>Heavy compute, existing Rust crates, OS access</td><td>Portable compute, running in the browser too</td><td>Parallelising code that is already fast enough JavaScript</td></tr>\n</tbody>\n</table>\n<p>The decision usually falls out of two questions. Does the code need to run in a browser as well? Then WebAssembly. Is the code already as fast in JavaScript as it would be in Rust, as the hash loop was here? Then <code>worker_threads</code> gets you the parallelism with no native toolchain to maintain. The addon is the answer when the work is both heavy and needs something only native code has: a Rust crate with no JavaScript counterpart, a system API, shared memory across threads, or the last factor of two or ten in speed. The crash mode in the table deserves weight: a segfault or abort in an addon kills the whole Node process, including every request in flight, where a bug in a worker thread only fails that worker. Keep the unsafe surface of the addon small, return errors instead of panicking, and do not accept inputs you have not validated, as the <code>fib(-1)</code> case showed.</p>\n<h2>Shipping it</h2>\n<p>An addon is a platform-specific binary, so distribution means building for each platform you support and getting the right one onto the user's machine. The napi-rs convention is a main package plus one small package per platform (<code>@yourscope/fastnode-darwin-arm64</code> and so on), listed as <code>optionalDependencies</code> of the main package so that <code>npm install</code> fetches only the one that matches. The CLI has commands for generating those packages and for the GitHub Actions workflow that builds the matrix on real runners (macOS, Windows, Linux with glibc and with musl) and publishes. Build Linux binaries in a container with an old glibc, for the same reason as Python manylinux wheels, or users on older distributions will see <code>GLIBC_2.xx not found</code> at <code>require</code>. Napi-rs 3 can also target WebAssembly (<code>wasm32-wasip1-threads</code>) as a fallback for platforms without a prebuilt binary, at a speed and compatibility cost. I built only for <code>aarch64-apple-darwin</code> here; the cross-platform matrix is configuration of the targets list and the workflow, and should be verified in CI on each target before release.</p>\n<h2>What goes wrong</h2>\n<table>\n<thead><tr><th>Symptom</th><th>Cause</th><th>Fix</th></tr></thead>\n<tbody>\n<tr><td><code>Cannot find module './fastnode.darwin-arm64.node'</code></td><td>Not built for this platform, or the binary name differs from <code>binaryName</code></td><td>Run <code>npm run build</code> on that platform; check the name</td></tr>\n<tr><td>Linker errors about undefined <code>napi_*</code> symbols</td><td><code>build.rs</code> missing or does not call <code>napi_build::setup()</code></td><td>Add it, with <code>napi-build</code> in <code>build-dependencies</code></td></tr>\n<tr><td>Callback receives one array argument</td><td>Tuple type used for the callback arguments</td><td>Wrap in <code>FnArgs&lt;(...)&gt;</code></td></tr>\n<tr><td>Server stalls during a call</td><td>Heavy work in a synchronous <code>#[napi]</code> function</td><td>Use <code>AsyncTask</code> or an <code>async fn</code></td></tr>\n<tr><td>Negative or large numbers produce odd results</td><td>Integer conversion wraps</td><td>Accept a wider type and validate</td></tr>\n<tr><td><code>cannot find crate tokio</code> in your own code</td><td>The <code>tokio_rt</code> feature does not re-export tokio to you</td><td>Add <code>tokio</code> as a direct dependency with the features you use</td></tr>\n<tr><td>Process aborts on a Rust panic</td><td>A panic unwound across the N-API boundary</td><td>Return <code>Result</code> for expected failures; treat panics as bugs</td></tr>\n</tbody>\n</table>\n<h2>Where this does not fit</h2>\n<p>If the slow part is I/O, an addon does not help; Node already does that well. If the function is cheap and called millions of times, the cost of crossing the boundary (argument conversion, a handle scope per call) can exceed the saving, so batch: pass an array or a <code>Buffer</code> and process it in one call. If you want to run the same code in the browser as well, compile to WebAssembly instead, as in <a href=\"/library/rust-to-webassembly\">Rust to WebAssembly</a>, since an addon runs only in Node. And if you only need a CLI tool in your build pipeline, ship a binary and call it. For the Python equivalent of this guide, with the same shape and different gotchas, see <a href=\"/library/python-extensions-pyo3-maturin\">Python extensions with PyO3 and maturin</a>, and for the C interface under both, <a href=\"/library/call-rust-from-c-cbindgen\">calling Rust from C</a>.</p>","date_published":"2026-11-03T09:58:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/python-extensions-pyo3-maturin","url":"https://rusthacks.dev/library/python-extensions-pyo3-maturin","title":"Python Extensions in Rust With PyO3 and Maturin Step by Step","summary":"PyO3 turns Rust functions and structs into Python modules, and Maturin builds and publishes them as wheels. A hot loop moves to Rust and the rest of the program stays Python.","content_html":"<p>Python is the right language for gluing things together and the wrong one for tight numeric loops, text crunching or anything you would like to run on all your cores at once. The standard fix is to write the hot function in a compiled language and call it from Python. In Rust, that is PyO3 for the bindings and maturin for building and packaging, and the result is a normal Python package: <code>pip install</code>, <code>import</code>, call. This guide builds one from an empty directory. You will write four Rust items (a function, a function that returns structured data, a function that releases Python's global interpreter lock, and a class), see Rust errors turn into Python exceptions, measure the speedup honestly, including the case where it is only 4 times and the case where it is 165 times, and build a wheel that installs on any Python from 3.9 up. Everything ran with Rust 1.99, PyO3 0.30.1, maturin 1.16.0 and CPython 3.14.6 on an Apple M1 Max. The performance numbers are from that machine; yours will differ in size, not in shape.</p>\n<h2>When this is worth doing</h2>\n<p>Decide before you write any Rust, because the boundary has a cost. A function call from Python into Rust takes tens of nanoseconds and converting arguments takes more, so wrapping a function that does a microsecond of work buys little. The win comes from work that stays inside Rust for a while: a loop over a million items, parsing a large string, compressing, hashing, geometry, a state machine, a CPU-bound task you want on several threads. If a profile of your Python shows one function eating most of the time and that function takes simple inputs (numbers, strings, byte buffers, lists of numbers) and returns a simple result, it is a good candidate. If the time goes into many small calls with Python objects, restructure first so that one call does a batch.</p>\n<h2>Set up an isolated environment</h2>\n<pre><code class=\"language-bash\">python3 -m venv .venv\n. .venv/bin/activate\npip install maturin\nmaturin --version</code></pre>\n<p>The virtual environment matters for a practical reason: <code>maturin develop</code> installs the extension into the active environment and refuses to run without one. Outside a venv it stops with \"Couldn't find a virtualenv or conda environment\", which is a feature, because it keeps a half-built development package out of your system Python. <code>pip install maturin</code> gives you version 1.16.0; any 1.x release works with the configuration below.</p>\n<h2>The project: three files</h2>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[package]\nname = \"fastlib\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\nname = \"fastlib\"\ncrate-type = [\"cdylib\"]\n\n[dependencies]\npyo3 = { version = \"0.30\", features = [\"abi3-py39\"] }</code></pre>\n<pre data-filename=\"pyproject.toml\"><code class=\"language-toml\">[build-system]\nrequires = [\"maturin>=1.7,&lt;2.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"fastlib\"\nrequires-python = \">=3.9\"\ndynamic = [\"version\"]\n\n[tool.maturin]\nfeatures = [\"pyo3/extension-module\"]</code></pre>\n<p>Each setting has a reason. <code>crate-type = [\"cdylib\"]</code> builds a C-compatible dynamic library, which is what Python loads as an extension module; the <code>[lib] name</code> must match the Python module name you will <code>import</code>. The <code>abi3-py39</code> feature builds against CPython's stable ABI, so one compiled wheel works on Python 3.9 and every later version, instead of one wheel per interpreter release. The cost is a restricted API (some PyO3 conveniences are unavailable and a few operations are slightly slower), and for most extensions it is the right trade. The <code>extension-module</code> feature, set through maturin, tells PyO3 not to link against <code>libpython</code>: the extension runs inside an already-running interpreter, which supplies the symbols. Putting it in <code>pyproject.toml</code> and not in <code>Cargo.toml</code> keeps <code>cargo test</code> working, since a test binary has no interpreter to provide them. The file <code>pyproject.toml</code> is what makes <code>pip install .</code> and <code>maturin</code> build the project; <code>dynamic = [\"version\"]</code> takes the version from <code>Cargo.toml</code> so it is written once.</p>\n<h2>The Rust module</h2>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">use pyo3::exceptions::PyValueError;\nuse pyo3::prelude::*;\nuse std::collections::HashMap;\n\n#[pymodule]\nmod fastlib {\n    use super::*;\n\n    /// Iterative Fibonacci, wrapping on overflow like a u64.\n    #[pyfunction]\n    fn fib(n: u32) -&gt; u64 {\n        let (mut a, mut b) = (0u64, 1u64);\n        for _ in 0..n {\n            (a, b) = (b, a.wrapping_add(b));\n        }\n        a\n    }\n\n    /// Count words in a string. Returns the 'n' most common as (word, count).\n    #[pyfunction]\n    fn top_words(text: &amp;str, n: usize) -&gt; PyResult&lt;Vec&lt;(String, usize)&gt;&gt; {\n        if n == 0 {\n            return Err(PyValueError::new_err(\"n must be at least 1\"));\n        }\n        let mut counts: HashMap&lt;String, usize&gt; = HashMap::new();\n        for w in text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {\n            *counts.entry(w.to_lowercase()).or_default() += 1;\n        }\n        let mut pairs: Vec&lt;_&gt; = counts.into_iter().collect();\n        pairs.sort_by(|a, b| b.1.cmp(&amp;a.1).then_with(|| a.0.cmp(&amp;b.0)));\n        pairs.truncate(n);\n        Ok(pairs)\n    }\n\n    /// Mix every value through a hash-like loop. The heavy part runs with the GIL released.\n    #[pyfunction]\n    fn churn(py: Python&lt;'_&gt;, values: Vec&lt;u64&gt;, rounds: u32) -&gt; u64 {\n        py.detach(|| {\n            values.iter().fold(0u64, |acc, v| {\n                let mut h = *v ^ 0xcbf29ce484222325;\n                for _ in 0..rounds {\n                    h = (h ^ (h &gt;&gt; 29)).wrapping_mul(0x100000001b3);\n                }\n                acc ^ h\n            })\n        })\n    }\n\n    /// Same work, but holding the GIL the whole time.\n    #[pyfunction]\n    fn churn_locked(values: Vec&lt;u64&gt;, rounds: u32) -&gt; u64 {\n        values.iter().fold(0u64, |acc, v| {\n            let mut h = *v ^ 0xcbf29ce484222325;\n            for _ in 0..rounds {\n                h = (h ^ (h &gt;&gt; 29)).wrapping_mul(0x100000001b3);\n            }\n            acc ^ h\n        })\n    }\n\n    #[pyclass]\n    struct Counter {\n        count: u64,\n        step: u64,\n    }\n\n    #[pymethods]\n    impl Counter {\n        #[new]\n        #[pyo3(signature = (step = 1))]\n        fn new(step: u64) -&gt; PyResult&lt;Self&gt; {\n            if step == 0 {\n                return Err(PyValueError::new_err(\"step must be non-zero\"));\n            }\n            Ok(Counter { count: 0, step })\n        }\n\n        fn bump(&amp;mut self) -&gt; u64 {\n            self.count += self.step;\n            self.count\n        }\n\n        #[getter]\n        fn count(&amp;self) -&gt; u64 {\n            self.count\n        }\n    }\n}\n</code></pre>\n<p>PyO3 0.30 uses a declarative style: a <code>#[pymodule] mod fastlib { ... }</code> block whose items become the module's contents. <code>#[pyfunction]</code> exposes a function, and PyO3 converts arguments and results automatically: <code>u32</code> arrives from a Python <code>int</code> (out-of-range values raise <code>OverflowError</code> for you), <code>&amp;str</code> borrows a Python string without copying, <code>Vec&lt;u64&gt;</code> converts from any list of ints, and a returned <code>Vec&lt;(String, usize)&gt;</code> becomes a Python list of tuples. Returning <code>PyResult&lt;T&gt;</code> lets a function raise: <code>PyValueError::new_err(\"...\")</code> becomes a <code>ValueError</code> in Python with that message. The doc comment (<code>///</code>) becomes the Python docstring. <code>#[pyclass]</code> and <code>#[pymethods]</code> define a Python class backed by a Rust struct: <code>#[new]</code> is <code>__init__</code>, <code>#[pyo3(signature = (step = 1))]</code> gives the argument a default, and <code>#[getter]</code> exposes a read-only attribute.</p>\n<h2>Build, install, call</h2>\n<pre><code class=\"language-bash\">maturin develop --release</code></pre>\n<p>That compiles in release mode and installs the module into the active environment as an editable package; the first build took about eight seconds. Without <code>--release</code> you get a debug build that can be 10 to 100 times slower, which is the most common reason for \"my Rust extension is not faster\". Now drive it from Python:</p>\n<pre data-filename=\"demo.py\"><code class=\"language-python\">import fastlib\nimport threading\nimport time\n\nprint(fastlib.fib(50), fastlib.fib(10))\nprint(fastlib.top_words(\"the cat and the hat and THE bat\", 2))\n\ntry:\n    fastlib.top_words(\"x\", 0)\nexcept ValueError as e:\n    print(\"ValueError:\", e)\n\ntry:\n    fastlib.fib(-1)\nexcept OverflowError as e:\n    print(\"OverflowError:\", e)\n\nc = fastlib.Counter(step=5)\nprint(c.bump(), c.bump(), c.count)\ntry:\n    fastlib.Counter(0)\nexcept ValueError as e:\n    print(\"ValueError:\", e)\n\nprint(fastlib.churn([1, 2, 3], 10))\nprint(type(fastlib.fib), fastlib.fib.__doc__)\n</code></pre>\n<pre><code class=\"language-text\">12586269025 55\n[('the', 3), ('and', 2)]\nValueError: n must be at least 1\nOverflowError: number too small to fit in target type\n5 10 10\nValueError: step must be non-zero\n13593663989309771750\n&lt;class 'builtin_function_or_method'&gt; Iterative Fibonacci, wrapping on overflow like a u64.</code></pre>\n<p>Walk through the output. <code>fib(50)</code> is 12,586,269,025, and the wrapping arithmetic documented in the docstring is visible through <code>fastlib.fib.__doc__</code>. The word count returned the two most common words as tuples. The three exception lines show the conversion in action: <code>top_words(\"x\", 0)</code> raised the <code>ValueError</code> from Rust, <code>fib(-1)</code> raised <code>OverflowError</code> without any code of ours, because a negative number does not fit a <code>u32</code>, and a <code>Counter(0)</code> raised <code>ValueError</code> from the constructor. The counter held its state in a Rust struct across calls (5, 10, then the <code>count</code> getter reports 10).</p>\n<h2>How much faster, measured</h2>\n<p>The benchmark compares each function with the obvious pure-Python version, taking the best of five runs:</p>\n<pre data-filename=\"bench.py\"><code class=\"language-python\">import fastlib, time, collections, re, threading\n\ndef py_fib(n):\n    a, b = 0, 1\n    for _ in range(n):\n        a, b = b, (a + b) &amp; 0xFFFFFFFFFFFFFFFF\n    return a\n\ndef timeit(f, repeat=5):\n    best = 1e9\n    for _ in range(repeat):\n        t = time.perf_counter(); f(); best = min(best, time.perf_counter() - t)\n    return best\n\nn = 1_000_000\np = timeit(lambda: py_fib(n)); r = timeit(lambda: fastlib.fib(n))\nprint(f\"fib({n}): python {p*1e3:8.2f} ms | rust {r*1e3:8.3f} ms | {p/r:6.0f}x\")\n\n# tiny call: overhead dominated\ntp = timeit(lambda: [py_fib(10) for _ in range(100000)]); tr = timeit(lambda: [fastlib.fib(10) for _ in range(100000)])\nprint(f\"100k calls of fib(10): python {tp*1e3:7.1f} ms | rust {tr*1e3:7.1f} ms | {tp/tr:4.1f}x  (per call {tr/100000*1e9:.0f} ns)\")\n\ntext = (\"the quick brown fox jumps over the lazy dog and the cat \" * 20000)\ndef py_top():\n    c = collections.Counter(w.lower() for w in re.split(r\"[^\\w]+\", text) if w)\n    return sorted(c.items(), key=lambda kv: (-kv[1], kv[0]))[:3]\na = timeit(py_top); b = timeit(lambda: fastlib.top_words(text, 3))\nprint(f\"top_words on {len(text)/1e6:.1f} MB: python {a*1e3:7.1f} ms | rust {b*1e3:7.1f} ms | {a/b:4.1f}x\")\nassert [list(x) for x in py_top()] == [list(x) for x in fastlib.top_words(text, 3)]\n\nvals = list(range(2000))\ndef run_threads(k, fn=fastlib.churn):\n    res = [0]*k; ts = [threading.Thread(target=lambda i=i: res.__setitem__(i, fn(vals, 20000))) for i in range(k)]\n    t = time.perf_counter(); [x.start() for x in ts]; [x.join() for x in ts]; return time.perf_counter() - t\nt1 = min(run_threads(1) for _ in range(3)); t4 = min(run_threads(4) for _ in range(3))\nprint(f\"churn with the GIL released: 1 thread {t1*1e3:.0f} ms, 4 threads {t4*1e3:.0f} ms for 4x the work\")\nl1 = min(run_threads(1, fastlib.churn_locked) for _ in range(3)); l4 = min(run_threads(4, fastlib.churn_locked) for _ in range(3))\nprint(f\"churn_locked (GIL held):       1 thread {l1*1e3:.0f} ms, 4 threads {l4*1e3:.0f} ms for 4x the work\")\n</code></pre>\n<pre><code class=\"language-text\">fib(1000000): python    53.26 ms | rust    0.322 ms |    165x\n100k calls of fib(10): python    37.3 ms | rust     6.2 ms |  6.0x  (per call 62 ns)\ntop_words on 1.1 MB: python    52.5 ms | rust    12.1 ms |  4.3x\nchurn with the GIL released: 1 thread 64 ms, 4 threads 66 ms for 4x the work\nchurn_locked (GIL held):       1 thread 64 ms, 4 threads 258 ms for 4x the work</code></pre>\n<p>Four results, four lessons. <strong>Pure compute is where the gain is:</strong> a million iterations of Fibonacci took 53 ms in Python and 0.32 ms in Rust, 165 times faster, because the Python version pays interpreter overhead on every iteration. <strong>Tiny calls are not free:</strong> a hundred thousand calls of <code>fib(10)</code> was only 6 times faster, with a call costing about 62 ns of which almost none is the Fibonacci. Crossing the boundary is the cost; batch work so that each call does a lot. <strong>Library-heavy code shrinks the gain:</strong> counting words in a 1.1 MB string was 4.3 times faster, a modest win, because Python's <code>re</code> and <code>Counter</code> are themselves compiled C code, so you are comparing Rust with C plus some interpreter glue. The assertion in the script checks that both versions return the same answer, which is the check you always want in a benchmark. <strong>The GIL is the multiplier:</strong> see the next section.</p>\n<h2>Releasing the GIL to use all your cores</h2>\n<p>CPython's global interpreter lock lets only one thread run Python code at a time. A Rust function that holds the lock while it works blocks every other Python thread, exactly as a slow Python function would. Inside <code>py.detach(|| ...)</code> the lock is released, and other threads run, including other calls into your Rust code. The rule: copy what you need out of Python objects first (the <code>Vec&lt;u64&gt;</code> argument is already a Rust-owned copy by the time the function body starts), then detach for the heavy part. The last two benchmark lines run the same hashing loop, once with <code>detach</code> and once without, on four Python threads each doing the same work as a single thread:</p>\n<table>\n<thead><tr><th>Variant</th><th>1 thread</th><th>4 threads (4x the work)</th></tr></thead>\n<tbody>\n<tr><td><code>churn</code>, GIL released</td><td>64 ms</td><td>66 ms</td></tr>\n<tr><td><code>churn_locked</code>, GIL held</td><td>64 ms</td><td>258 ms</td></tr>\n</tbody>\n</table>\n<p>With the lock released, four threads did four times the work in the same wall time: real parallelism, from a language with a global lock. With the lock held, the same work took four times as long, because the threads queued behind one another. That difference is the strongest reason to use Rust from Python for CPU-bound work, and it is a one-line change. A caution from the first attempt at this benchmark: an earlier version detached only around a trivial sum and converted a two-million-element list while holding the lock, and four threads took four times as long. The conversion from a Python list is itself work under the lock, so a function whose cost is mostly argument conversion gains nothing from <code>detach</code>. Pass big data as a buffer (<code>bytes</code>, or a NumPy array through the <code>numpy</code> crate) that Rust can read without converting element by element. Older PyO3 code spells it <code>py.allow_threads</code>; the name changed to <code>detach</code> in recent releases, along with <code>with_gil</code> becoming <code>attach</code>.</p>\n<h2>Big inputs, interruptions and panics</h2>\n<p>Three more behaviours decide whether an extension feels like a real Python library. First, large inputs. A <code>&amp;[u8]</code> parameter borrows the contents of a Python <code>bytes</code> object directly, with no copy, so a checksum over 100 MB costs only the checksum. It accepts <code>bytes</code> and nothing else; a <code>bytearray</code> or <code>str</code> is a <code>TypeError</code> with an accurate message. Second, long loops should stay interruptible: a Rust loop that never returns to Python ignores Ctrl-C, so call <code>py.check_signals()?</code> every few thousand iterations, which raises <code>KeyboardInterrupt</code> when a signal is pending. Third, panics: a Rust panic must not unwind into the interpreter, so PyO3 catches it and raises <code>PanicException</code>, deriving from <code>BaseException</code> rather than <code>Exception</code> so that ordinary <code>except Exception</code> handlers do not swallow it. Three small functions added to the module show all three:</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">/// FNV-1a over a bytes-like object, without copying it out of Python.\n#[pyfunction]\nfn checksum(data: &amp;[u8]) -> u64 {\n    data.iter().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3))\n}\n\n/// Busy loop that stays interruptible from Python.\n#[pyfunction]\nfn spin(py: Python&lt;'_>, seconds: f64) -> PyResult&lt;u64> {\n    let end = std::time::Instant::now() + std::time::Duration::from_secs_f64(seconds);\n    let mut n = 0u64;\n    while std::time::Instant::now() &lt; end {\n        n += 1;\n        if n % 1000 == 0 {\n            py.check_signals()?;\n        }\n    }\n    Ok(n)\n}\n\n/// Always panics, to show what Python sees.\n#[pyfunction]\nfn explode() -> u32 {\n    let v: Vec&lt;u32> = Vec::new();\n    v[3]\n}</code></pre>\n<pre data-filename=\"extras.py\"><code class=\"language-python\">import fastlib, signal, time\n\ndata = bytes(range(256)) * 400_000\nt = time.perf_counter(); c = fastlib.checksum(data); dt = time.perf_counter() - t\nprint(f\"checksum of {len(data)/1e6:.0f} MB bytes: {c:#018x} in {dt*1e3:.0f} ms\")\nfor bad in (bytearray(b\"abc\"), \"text\"):\n    try:\n        fastlib.checksum(bad)\n    except TypeError as e:\n        print(f\"TypeError for {type(bad).__name__}:\", str(e).replace(chr(10), \" \"))\n\nsignal.signal(signal.SIGALRM, lambda *a: signal.raise_signal(signal.SIGINT))\nsignal.setitimer(signal.ITIMER_REAL, 0.2)\nt = time.perf_counter()\ntry:\n    fastlib.spin(5.0)\nexcept KeyboardInterrupt:\n    print(f\"spin(5.0) interrupted after {time.perf_counter() - t:.1f} s: KeyboardInterrupt\")\n\ntry:\n    fastlib.explode()\nexcept BaseException as e:\n    print(type(e).__name__, \"-\", e, \"| is an Exception:\", isinstance(e, Exception))\n</code></pre>\n<pre><code class=\"language-text\">checksum of 102 MB bytes: 0x07ec59baf399a325 in 134 ms\nTypeError for bytearray: 'bytearray' object is not an instance of 'bytes'\nTypeError for str: 'str' object is not an instance of 'bytes'\nspin(5.0) interrupted after 0.2 s: KeyboardInterrupt\nPanicException - index out of bounds: the len is 0 but the index is 3 | is an Exception: False</code></pre>\n<p>The checksum ran over 102 MB in 134 ms with no copy, about 760 MB per second for a byte-at-a-time hash. The interrupt test schedules a SIGINT after 0.2 s while Rust spins for five: the call stopped after 0.2 s with <code>KeyboardInterrupt</code>, exactly as a Python loop would. The panic produced <code>PanicException</code> with Rust's message and <code>isinstance(e, Exception)</code> was false; the panic message is also printed to standard error by Rust's hook. The practical rules follow directly: expected failures return <code>PyResult</code> (and become precise Python exceptions), panics are for bugs, and any loop longer than a few milliseconds checks for signals.</p>\n<h2>The direction that loses: Rust calling Python</h2>\n<p>Everything above sends work into Rust. The reverse, a Rust function that calls a Python callable for each item, is worth measuring before you design around it, because it is the case where the extension is slower than the code it replaced:</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">#[pyfunction]\nfn apply(f: &amp;Bound&lt;'_, PyAny>, values: Vec&lt;i64>) -> PyResult&lt;Vec&lt;i64>> {\n    values.into_iter().map(|v| f.call1((v,))?.extract::&lt;i64>()).collect()\n}</code></pre>\n<pre><code class=\"language-text\">1M callbacks: python list comprehension 47 ms | Rust calling the same lambda 97 ms\nexception from the callback propagates: division by zero</code></pre>\n<p>A million calls of <code>lambda v: v * 2</code> took 47 ms in a Python list comprehension and 97 ms from Rust: about twice as slow, because each callback builds an argument tuple, enters the interpreter's call machinery and converts the result back, while the comprehension runs on a fast path inside the interpreter. The error handling is the good news: an exception raised in the callback (<code>ZeroDivisionError</code>) came out of the Rust function as itself, because <code>?</code> on a <code>PyResult</code> propagates it. The design rule is that the callback boundary is expensive, so give Rust data and let it work without calling back. If the user must supply behaviour, accept a small description of it (an enum or a string naming an operation) instead of a function, or make the callback process a whole batch per call.</p>\n<h2>A package that mixes Python and Rust</h2>\n<p>A pure extension module is fine for a small helper. A real library usually wants a Python layer on top: a friendlier API, docstrings, type hints, convenience functions that do not need Rust. Maturin supports that with a source directory for the Python code and a private compiled module underneath, conventionally named <code>_core</code>. The package imports from it, and users only see the public names:</p>\n<pre data-filename=\"pyproject.toml\"><code class=\"language-toml\">[build-system]\nrequires = [\"maturin&gt;=1.7,&lt;2.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"fastlib\"\nrequires-python = \"&gt;=3.9\"\ndynamic = [\"version\"]\n\n[tool.maturin]\npython-source = \"python\"\nmodule-name = \"fastlib._core\"\nfeatures = [\"pyo3/extension-module\"]\n</code></pre>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">use pyo3::prelude::*;\n\n#[pymodule]\nmod _core {\n    use super::*;\n\n    #[pyfunction]\n    fn fib(n: u32) -&gt; u64 {\n        let (mut a, mut b) = (0u64, 1u64);\n        for _ in 0..n {\n            (a, b) = (b, a.wrapping_add(b));\n        }\n        a\n    }\n}\n</code></pre>\n<pre data-filename=\"python/fastlib/__init__.py\"><code class=\"language-python\">\"\"\"Fast helpers backed by Rust.\"\"\"\nfrom ._core import fib\n\n__all__ = [\"fib\", \"fib_many\"]\n\n\ndef fib_many(ns):\n    \"\"\"Pure Python convenience layer on top of the Rust core.\"\"\"\n    return [fib(n) for n in ns]\n</code></pre>\n<p>Three names must agree: the Cargo library name (<code>_core</code>), the <code>#[pymodule]</code> module name (<code>_core</code>) and the last component of <code>module-name</code> in <code>pyproject.toml</code> (<code>fastlib._core</code>). After <code>maturin develop</code> the package imported correctly and <code>fastlib.fib_many([10, 20, 30])</code> returned <code>[55, 6765, 832040]</code>, with the compiled module landing at <code>fastlib/_core.abi3.so</code> next to the Python files. Editing a Python file takes effect immediately in the editable install; editing Rust needs another <code>maturin develop</code>. This layout also gives you a place for the <code>.pyi</code> stub file and the <code>py.typed</code> marker, which together make type checkers understand the package.</p>\n<h2>Build wheels you can ship</h2>\n<pre><code class=\"language-bash\">maturin build --release\nls target/wheels\npip install target/wheels/fastlib-0.1.0-cp39-abi3-macosx_11_0_arm64.whl</code></pre>\n<p>The wheel produced here was named <code>fastlib-0.1.0-cp39-abi3-macosx_11_0_arm64.whl</code> and was 251 KB. The tags say it all: <code>cp39-abi3</code> means \"CPython 3.9 or newer, stable ABI\", and the platform part says macOS on ARM. You need one wheel per operating system and CPU architecture, not per Python version. Build them in CI with the <code>PyO3/maturin-action</code> GitHub Action, which runs inside the manylinux containers so Linux wheels work on every distribution, and publish with <code>maturin publish</code> or <code>twine</code>. Linux wheels must be built against an old glibc (the <code>manylinux</code> tag), which is exactly what the action's containers provide; a wheel built on your newest laptop distribution will fail to import on older servers with a <code>GLIBC_2.xx not found</code> error.</p>\n<h2>What goes wrong</h2>\n<table>\n<thead><tr><th>Symptom</th><th>Cause</th><th>Fix</th></tr></thead>\n<tbody>\n<tr><td>\"Couldn't find a virtualenv or conda environment\"</td><td><code>maturin develop</code> outside a venv</td><td>Create and activate one, or use <code>maturin build</code> plus <code>pip install</code></td></tr>\n<tr><td><code>ImportError: dynamic module does not define module export function</code></td><td>The <code>[lib] name</code> or module name differs from the import name</td><td>Make <code>[lib] name</code>, the <code>#[pymodule]</code> name and the import identical</td></tr>\n<tr><td>Rust version is not faster</td><td>Debug build</td><td><code>maturin develop --release</code></td></tr>\n<tr><td>Linker errors about <code>_Py*</code> symbols in <code>cargo test</code></td><td><code>extension-module</code> set in <code>Cargo.toml</code></td><td>Put it in <code>pyproject.toml</code> as shown</td></tr>\n<tr><td><code>pyo3_runtime.PanicException</code></td><td>A Rust panic crossed into Python</td><td>Return <code>PyResult</code> for expected failures; a panic is a bug, and the exception derives from <code>BaseException</code></td></tr>\n<tr><td>Ctrl-C does nothing during a long call</td><td>The Rust loop never checks for signals</td><td>Call <code>py.check_signals()?</code> periodically in long loops</td></tr>\n<tr><td>Threads do not speed up</td><td>The heavy part runs with the GIL held, or conversion dominates</td><td><code>py.detach</code> around the compute; pass buffers, not lists</td></tr>\n<tr><td>Wheel fails on old Linux: <code>GLIBC_2.xx not found</code></td><td>Built outside manylinux</td><td>Build with <code>maturin-action</code> or a manylinux container</td></tr>\n</tbody>\n</table>\n<h2>Test it like a Python package</h2>\n<p>Test the Rust logic with <code>cargo test</code> on plain functions (keep the algorithm in an ordinary function and make the <code>#[pyfunction]</code> a thin wrapper), and test the Python surface with <code>pytest</code>: import the module, call every function, assert on results and on the exact exception types. The most valuable kind of test compares the Rust implementation against a slow, obviously correct Python one over many inputs, including the edges:</p>\n<pre data-filename=\"tests/test_fib.py\"><code class=\"language-python\">import pytest\nimport fastlib\n\n\ndef python_fib(n):\n    a, b = 0, 1\n    for _ in range(n):\n        a, b = b, (a + b) &amp; 0xFFFFFFFFFFFFFFFF\n    return a\n\n\n@pytest.mark.parametrize(\"n\", [0, 1, 2, 10, 50, 93, 94, 1000])\ndef test_matches_python(n):\n    assert fastlib.fib(n) == python_fib(n)\n\n\ndef test_negative_is_overflow_error():\n    with pytest.raises(OverflowError):\n        fastlib.fib(-1)\n\n\ndef test_fib_many_keeps_order():\n    assert fastlib.fib_many([5, 1, 10]) == [5, 1, 55]\n</code></pre>\n<pre><code class=\"language-text\">..........                                                               [100%]\n10 passed in 0.01s</code></pre>\n<p>The parametrised cases include 93 and 94, the boundary where a Fibonacci number stops fitting in 64 bits and the Rust code wraps; the Python reference masks to 64 bits to match, so the test pins down that the behaviour is intentional and the same on both sides. The <code>OverflowError</code> test pins the conversion behaviour that PyO3 gives for free, which protects you from a future change of the argument type. Ship a <code>fastlib.pyi</code> stub file next to the module (or inside the package, with an empty <code>py.typed</code> marker) so editors and type checkers know the signatures; PyO3 does not generate stubs for you, and a hand-written 15-line file is enough. Add one benchmark to the suite that fails if the Rust path becomes slower than the Python fallback, so the reason for the dependency stays true.</p>\n<h2>Where this does not fit</h2>\n<p>If the bottleneck is I/O (network, disk), Rust will not help; use async Python. If it is a numeric array operation that NumPy, Polars or JAX already do, call those. If you need a command-line tool or a service, write a Rust binary and call it, avoiding the boundary altogether. And when the Python side needs to call back into Python many times from Rust, the cost of crossing in both directions can erase the gain, so keep the callbacks coarse. The same approach for JavaScript is in <a href=\"/library/node-addons-napi-rs\">Node addons with napi-rs</a>, and the C interface underneath both is explained in <a href=\"/library/call-rust-from-c-cbindgen\">calling Rust from C</a>.</p>","date_published":"2026-11-03T09:56:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/safe-rust-bindings-c-library","url":"https://rusthacks.dev/library/safe-rust-bindings-c-library","title":"Safe Rust Bindings for a C Library a Worked Example","summary":"A good binding is two crates. The sys crate mirrors the C header exactly, and the safe crate hides the pointers behind types that cannot be misused.","content_html":"<p>Raw bindings from <code>bindgen</code> compile and call C correctly, and are almost unusable. Every function is <code>unsafe</code>, every string is a <code>*const c_char</code>, every handle must be freed by hand, and every rule about who owns what lives in a C comment that the compiler never reads. A safe binding turns those comments into types. When it is done, a Rust program can use the C library with no <code>unsafe</code> of its own, and the mistakes that C makes easy, a use after free, a leak, a double free, a call from the wrong thread, become compile errors.</p>\n<p>This guide wraps the small C key-value store from the previous guide (<a href=\"/library/call-c-from-rust-bindgen\">calling C from Rust</a>) in a safe crate, one decision at a time. Each decision is the answer to a rule in the C header, and the compiler's refusals at the end prove the rules are enforced. It was built and tested on Rust 1.99 (edition 2024) with <code>cc</code> 1.11.1, and every output block is real.</p>\n<h2>What the C header promised</h2>\n<p>Wrapping a library starts with reading its contract, because a safe API is only as good as your understanding of the rules it encodes. The store's header makes these promises:</p>\n<ol><li><code>kv_open</code> returns a handle or NULL, and the handle must be freed exactly once with <code>kv_close</code>.</li><li><code>kv_set</code> copies the key and value, so the caller keeps ownership of what it passed. It returns 0 or -1.</li><li><code>kv_get</code> returns a pointer owned by the store, valid until the next <code>kv_set</code> of that key or <code>kv_close</code>, or NULL if absent.</li><li><code>kv_foreach</code> calls a function pointer with an opaque <code>void *</code>.</li><li>The header says nothing about threads, so none can be assumed.</li></ol>\n<p>Each of them maps to a Rust feature. Promise 1 becomes ownership and <code>Drop</code>. Promise 2 becomes <code>&amp;str</code> arguments converted to temporary <code>CString</code> values. Promise 3 becomes a lifetime. Promise 4 becomes a generic closure parameter. Promise 5 becomes the absence of <code>Send</code> and <code>Sync</code>.</p>\n<h2>Layer one: the private <code>sys</code> module</h2>\n<p>The raw declarations live in their own module and are not public. Nothing outside the crate sees an <code>unsafe</code> function.</p>\n<pre data-filename=\"src/sys.rs\"><code class=\"language-rust\">//! Raw declarations of the C API. Nothing outside this crate sees them.\n#![allow(non_camel_case_types)]\nuse std::ffi::{c_char, c_int, c_void};\n\n#[repr(C)]\npub struct kv_t {\n    _private: [u8; 0],\n}\n\npub type kv_visit_fn = Option&lt;unsafe extern \"C\" fn(*const c_char, *const c_char, *mut c_void)&gt;;\n\nunsafe extern \"C\" {\n    pub fn kv_open(name: *const c_char) -&gt; *mut kv_t;\n    pub fn kv_set(store: *mut kv_t, key: *const c_char, value: *const c_char) -&gt; c_int;\n    pub fn kv_get(store: *const kv_t, key: *const c_char) -&gt; *const c_char;\n    pub fn kv_len(store: *const kv_t) -&gt; usize;\n    pub fn kv_foreach(store: *const kv_t, visit: kv_visit_fn, user_data: *mut c_void);\n    pub fn kv_close(store: *mut kv_t);\n}</code></pre>\n<p>You can write this by hand for a small library or generate it with <code>bindgen</code>, as in the previous guide. Either way, it should be dull and mechanical. All the thinking happens one layer up. In a published binding, this layer would be a separate <code>-sys</code> crate with <code>links = \"kv\"</code> in its manifest, but a private module keeps the example in one file.</p>\n<h2>Layer two: the safe API</h2>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">//! A safe interface to the C `kv` store.\n\nmod sys;\n\nuse std::ffi::{c_char, c_void, CStr, CString, NulError};\nuse std::ptr::NonNull;\n\n/// Everything that can go wrong, as a normal Rust error.\n#[derive(Debug, PartialEq, Eq)]\npub enum Error {\n    /// A key, value or name contained a NUL byte, which a C string cannot hold.\n    InteriorNul { position: usize },\n    /// The C library reported failure, in practice that it ran out of memory.\n    Failed,\n    /// The library returned text that is not valid UTF-8.\n    NotUtf8,\n}\n\nimpl From&lt;NulError&gt; for Error {\n    fn from(e: NulError) -&gt; Self {\n        Error::InteriorNul { position: e.nul_position() }\n    }\n}\n\nimpl std::fmt::Display for Error {\n    fn fmt(&amp;self, f: &amp;mut std::fmt::Formatter&lt;'_&gt;) -&gt; std::fmt::Result {\n        match self {\n            Error::InteriorNul { position } =&gt; write!(f, \"text contains a NUL byte at position {position}\"),\n            Error::Failed =&gt; f.write_str(\"the kv library reported an error\"),\n            Error::NotUtf8 =&gt; f.write_str(\"the kv library returned text that is not UTF-8\"),\n        }\n    }\n}\n\nimpl std::error::Error for Error {}\n\n/// An owned handle to a store. The C store is freed when this value is dropped.\n///\n/// Holding a `NonNull` makes the type `!Send` and `!Sync` automatically. The C library\n/// does not document thread safety, so we do not claim it.\npub struct Kv {\n    raw: NonNull&lt;sys::kv_t&gt;,\n}\n\nimpl Kv {\n    pub fn open(name: &amp;str) -&gt; Result&lt;Kv, Error&gt; {\n        let name = CString::new(name)?;\n        // SAFETY: `name` is a valid NUL-terminated string that outlives the call.\n        let raw = unsafe { sys::kv_open(name.as_ptr()) };\n        NonNull::new(raw).map(|raw| Kv { raw }).ok_or(Error::Failed)\n    }\n\n    pub fn set(&amp;mut self, key: &amp;str, value: &amp;str) -&gt; Result&lt;(), Error&gt; {\n        let (key, value) = (CString::new(key)?, CString::new(value)?);\n        // SAFETY: `self.raw` is live (we own it), both strings are valid for the call,\n        // and `kv_set` copies them. `&amp;mut self` rules out a concurrent reader.\n        let rc = unsafe { sys::kv_set(self.raw.as_ptr(), key.as_ptr(), value.as_ptr()) };\n        if rc == 0 { Ok(()) } else { Err(Error::Failed) }\n    }\n\n    /// The value is borrowed straight from the C store, with no copy. The `&amp;self` borrow\n    /// lasts as long as the returned `&amp;str`, so `set` (which needs `&amp;mut self`) cannot run\n    /// while it is in use. That is exactly the validity rule the C header documents.\n    pub fn get(&amp;self, key: &amp;str) -&gt; Result&lt;Option&lt;&amp;str&gt;, Error&gt; {\n        let key = CString::new(key)?;\n        // SAFETY: `self.raw` is live; `key` is valid for the call.\n        let ptr = unsafe { sys::kv_get(self.raw.as_ptr(), key.as_ptr()) };\n        if ptr.is_null() {\n            return Ok(None);\n        }\n        // SAFETY: the C library returns a NUL-terminated string owned by the store, valid\n        // until the next `kv_set` of this key or `kv_close`. Both need `&amp;mut self` or\n        // ownership, which the returned lifetime (tied to `&amp;self`) forbids.\n        let text = unsafe { CStr::from_ptr(ptr) };\n        text.to_str().map(Some).map_err(|_| Error::NotUtf8)\n    }\n\n    pub fn len(&amp;self) -&gt; usize {\n        // SAFETY: `self.raw` is live.\n        unsafe { sys::kv_len(self.raw.as_ptr()) }\n    }\n\n    pub fn is_empty(&amp;self) -&gt; bool {\n        self.len() == 0\n    }\n\n    /// Snapshot of every entry, copied into owned strings so it can outlive later `set` calls.\n    ///\n    /// ```\n    /// use safekv::Kv;\n    ///\n    /// # fn main() -&gt; Result&lt;(), safekv::Error&gt; {\n    /// let mut kv = Kv::open(\"doc\")?;\n    /// kv.set(\"a\", \"1\")?;\n    /// kv.set(\"b\", \"2\")?;\n    /// let snapshot = kv.entries();\n    /// kv.set(\"a\", \"changed\")?; // fine: the snapshot owns its data\n    /// assert_eq!(snapshot, [(\"a\".to_string(), \"1\".to_string()), (\"b\".to_string(), \"2\".to_string())]);\n    /// # Ok(())\n    /// # }\n    /// ```\n    pub fn entries(&amp;self) -&gt; Vec&lt;(String, String)&gt; {\n        let mut out = Vec::with_capacity(self.len());\n        self.for_each(|k, v| out.push((k.to_owned(), v.to_owned())));\n        out\n    }\n\n    /// Visit every entry in insertion order. The closure may capture and mutate local state.\n    pub fn for_each&lt;F: FnMut(&amp;str, &amp;str)&gt;(&amp;self, mut f: F) {\n        unsafe extern \"C\" fn trampoline&lt;F: FnMut(&amp;str, &amp;str)&gt;(k: *const c_char, v: *const c_char, user: *mut c_void) {\n            // SAFETY: `user` is the `&amp;mut F` created below and is live for the whole call.\n            let f = unsafe { &amp;mut *user.cast::&lt;F&gt;() };\n            let (k, v) = unsafe { (CStr::from_ptr(k), CStr::from_ptr(v)) };\n            // A panic must not unwind into C. Dropping it here loses the panic, so record it\n            // instead in a real wrapper; this version simply stops propagating.\n            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&amp;k.to_string_lossy(), &amp;v.to_string_lossy())));\n        }\n        // SAFETY: `self.raw` is live and `f` outlives the call, which is the only time C uses the pointer.\n        unsafe { sys::kv_foreach(self.raw.as_ptr(), Some(trampoline::&lt;F&gt;), (&amp;mut f as *mut F).cast()) };\n    }\n}\n\nimpl Drop for Kv {\n    fn drop(&amp;mut self) {\n        // SAFETY: we own the handle and this is the only place it is freed.\n        unsafe { sys::kv_close(self.raw.as_ptr()) };\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn set_get_and_overwrite() {\n        let mut kv = Kv::open(\"t\").unwrap();\n        assert!(kv.is_empty());\n        kv.set(\"host\", \"a.test\").unwrap();\n        kv.set(\"port\", \"80\").unwrap();\n        kv.set(\"host\", \"b.test\").unwrap();\n        assert_eq!(kv.len(), 2);\n        assert_eq!(kv.get(\"host\").unwrap(), Some(\"b.test\"));\n        assert_eq!(kv.get(\"absent\").unwrap(), None);\n    }\n\n    #[test]\n    fn interior_nul_is_an_error_not_a_crash() {\n        let mut kv = Kv::open(\"t\").unwrap();\n        assert_eq!(kv.set(\"ba\\0d\", \"x\"), Err(Error::InteriorNul { position: 2 }));\n        assert!(matches!(Kv::open(\"na\\0me\"), Err(Error::InteriorNul { .. })));\n    }\n\n    #[test]\n    fn for_each_can_capture_state() {\n        let mut kv = Kv::open(\"t\").unwrap();\n        for (k, v) in [(\"a\", \"1\"), (\"b\", \"22\")] {\n            kv.set(k, v).unwrap();\n        }\n        let mut seen = Vec::new();\n        kv.for_each(|k, v| seen.push(format!(\"{k}={v}\")));\n        assert_eq!(seen, [\"a=1\", \"b=22\"]);\n    }\n\n    #[test]\n    fn entries_snapshot_survives_a_later_set() {\n        let mut kv = Kv::open(\"t\").unwrap();\n        kv.set(\"k\", \"old\").unwrap();\n        let snapshot = kv.entries();\n        kv.set(\"k\", \"new\").unwrap();\n        assert_eq!(snapshot, [(\"k\".to_string(), \"old\".to_string())]);\n        assert_eq!(kv.get(\"k\").unwrap(), Some(\"new\"));\n    }\n\n    #[test]\n    fn many_open_and_close_cycles_do_not_crash() {\n        for i in 0..2_000 {\n            let mut kv = Kv::open(\"cycle\").unwrap();\n            kv.set(&amp;format!(\"key{i}\"), \"value\").unwrap();\n        }\n    }\n\n    #[test]\n    fn unicode_round_trips() {\n        let mut kv = Kv::open(\"t\").unwrap();\n        kv.set(\"city\", \"東京\").unwrap();\n        assert_eq!(kv.get(\"city\").unwrap(), Some(\"東京\"));\n    }\n}</code></pre>\n<p>Read the file against the five promises.</p>\n<h3>Ownership: <code>Kv</code> owns the handle</h3>\n<p><code>Kv</code> holds a <code>NonNull&lt;kv_t&gt;</code>, a pointer that the type guarantees is not null. <code>Kv::open</code> checks the C return value once, converting a NULL into <code>Err(Error::Failed)</code>. From then on every method can assume the handle is valid, and the check cannot be forgotten, because it lives in the only constructor. The fields are private, so no one can build a <code>Kv</code> around a bad pointer.</p>\n<p>The <code>Drop</code> impl calls <code>kv_close</code>. That single line eliminates the leak, because the handle is freed when the value goes out of scope on every path, including early returns and panics. It eliminates the double free, because Rust drops a value once and the move rules stop anyone from using it afterward. And it eliminates the use after free, as the third compile error below shows. Of the three bugs C programmers fear most, ownership types remove them entirely.</p>\n<h3>Strings: errors instead of crashes</h3>\n<p>The C side wants NUL-terminated strings, and Rust strings can contain NUL bytes. Every method that takes text converts it through <code>CString::new</code>, which returns an error for an embedded NUL. The wrapper does not <code>unwrap</code> that error; it turns it into <code>Error::InteriorNul { position }</code> through a <code>From</code> impl so the <code>?</code> operator does the conversion. The demo exercises it:</p>\n<pre><code class=\"language-text\">error: text contains a NUL byte at position 3</code></pre>\n<p>A caller who passes user input with a stray NUL gets a typed error they can handle, rather than a truncated key, which is what C would have silently stored.</p>\n<h3>Lifetimes: encoding the pointer validity rule</h3>\n<p>This is the most important design decision in the file, and it is worth slowing down for. The header says <code>kv_get</code>'s result is valid until the next <code>kv_set</code> of that key or <code>kv_close</code>. There are two ways to wrap it.</p>\n<p>The cautious way copies the text into an owned <code>String</code> before returning, at the cost of an allocation on every read. It is always safe, and the right choice if reads are rare or the values small. The zero-copy way, used here, returns <code>&amp;str</code> borrowed directly from the C store, and lets the borrow checker enforce the validity rule. The signature <code>fn get(&amp;self, key: &amp;str) -&gt; Result&lt;Option&lt;&amp;str&gt;, Error&gt;</code> has an elided lifetime that ties the returned <code>&amp;str</code> to the borrow of <code>self</code>. While that reference is alive, <code>self</code> is borrowed, and <code>set</code> needs <code>&amp;mut self</code>, so the compiler refuses to compile a program that would invalidate the pointer. The safe interface is a precise transcription of the C comment.</p>\n<p>Here is a program that violates the rule, and what the compiler said:</p>\n<pre data-filename=\"examples/bad1.rs\"><code class=\"language-rust\">use safekv::Kv;\n\nfn main() {\n    let mut kv = Kv::open(\"s\").unwrap();\n    kv.set(\"host\", \"a.test\").unwrap();\n    let host = kv.get(\"host\").unwrap().unwrap(); // borrowed from the C store\n    kv.set(\"host\", \"b.test\").unwrap();           // C frees the old value here\n    println!(\"{host}\");\n}</code></pre>\n<pre><code class=\"language-text\">error[E0502]: cannot borrow `kv` as mutable because it is also borrowed as immutable\n --&gt; examples/bad1.rs:7:5\n  |\n6 |     let host = kv.get(\"host\").unwrap().unwrap(); // borrowed from the C store\n  |                -- immutable borrow occurs here\n7 |     kv.set(\"host\", \"b.test\").unwrap();           // C frees the old value here\n  |     ^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here\n8 |     println!(\"{host}\");\n  |                ---- immutable borrow later used here</code></pre>\n<p>In C the same sequence compiles, runs, and reads freed memory. In Rust it does not compile, and the message points at the exact line where the C library would free the string. The cost of the guarantee is that code which holds a value across a <code>set</code> must copy it first (<code>.to_string()</code>), which is exactly what the C comment told callers to do.</p>\n<h3>Mutability: <code>&amp;self</code> and <code>&amp;mut self</code></h3>\n<p>The split between <code>get</code>, <code>len</code> and <code>for_each</code> taking <code>&amp;self</code>, and <code>set</code> taking <code>&amp;mut self</code>, is the other half of the lifetime rule. It also happens to match the semantics: reading never changes the store, writing does. In C every function takes the same plain pointer, so the distinction is invisible; the wrapper adds it, and with it the ability to have many readers or one writer, checked at compile time.</p>\n<h3>Callbacks: a generic trampoline</h3>\n<p><code>kv_foreach</code> takes a bare C function pointer and a <code>void *</code>. The wrapper's <code>for_each</code> accepts any <code>FnMut(&amp;str, &amp;str)</code>, so the caller can capture variables. Inside is the trampoline pattern: a nested generic <code>extern \"C\"</code> function that casts the <code>void *</code> back to the closure and calls it. A panic inside the closure must not unwind into C, so the call sits in <code>catch_unwind</code>. The comment in the code is honest about the limit: the panic is swallowed rather than re-raised in the caller, and a production wrapper would record it and resume it after <code>kv_foreach</code> returns. The trampoline is <code>unsafe</code> code that no user of the crate has to read or write.</p>\n<h2>Using it</h2>\n<p>With the unsafe code behind the interface, the demo program has none:</p>\n<pre data-filename=\"examples/demo.rs\"><code class=\"language-rust\">use safekv::Kv;\n\nfn main() -&gt; Result&lt;(), safekv::Error&gt; {\n    let mut kv = Kv::open(\"settings\")?;\n    kv.set(\"host\", \"example.test\")?;\n    kv.set(\"port\", \"8080\")?;\n\n    println!(\"host = {:?}\", kv.get(\"host\")?);\n    println!(\"nope = {:?}\", kv.get(\"nope\")?);\n\n    let mut total = 0;\n    kv.for_each(|key, value| {\n        total += key.len() + value.len();\n        println!(\"  {key}: {value}\");\n    });\n    println!(\"{} entries, {} bytes of text\", kv.len(), total);\n\n    match kv.set(\"bad\\0key\", \"x\") {\n        Err(e) =&gt; println!(\"error: {e}\"),\n        Ok(()) =&gt; unreachable!(),\n    }\n    Ok(())\n}</code></pre>\n<pre><code class=\"language-text\">host = Some(\"example.test\")\nnope = None\n  host: example.test\n  port: 8080\n2 entries, 24 bytes of text\nerror: text contains a NUL byte at position 3</code></pre>\n<p>The <code>?</code> operators carry errors up through <code>main</code>, absent keys are <code>None</code> rather than a null pointer to be checked, the closure updates <code>total</code> as a normal local, and the handle is freed when <code>kv</code> goes out of scope, with no call to remember. This is the point of the exercise: the demo reads like a program in any other safe language.</p>\n<h2>Threads: the decision to leave <code>Send</code> off</h2>\n<p>The compiler decided something on the crate's behalf. <code>NonNull&lt;T&gt;</code> is neither <code>Send</code> nor <code>Sync</code>, so <code>Kv</code> is neither, automatically. The header was silent about thread safety, so silence is the correct default: a library that does not promise to be thread-safe should be assumed not to be. The effect is that moving a <code>Kv</code> into another thread fails to compile:</p>\n<pre><code class=\"language-text\">error[E0277]: `NonNull&lt;safekv::sys::kv_t&gt;` cannot be sent between threads safely\n  --&gt; examples/bad2.rs:5:24\n   |\n 5 |       std::thread::spawn(move || {\n   |       ------------------ ^------\n   |       |                  |\n   |  _____|__________________within this `{closure@examples/bad2.rs:5:24: 5:31}`\n   | |     |\n   | |     required by a bound introduced by this call\n 6 | |         println!(\"{}\", kv.len());\n 7 | |     })\n   | |_____^ `NonNull&lt;safekv::sys::kv_t&gt;` cannot be sent between threads safely\n   |\n   = help: within `{closure@examples/bad2.rs:5:24: 5:31}`, the trait `Send` is not implemented for `NonNull&lt;safekv::sys::kv_t&gt;`\nnote: required because it appears within the type `Kv`</code></pre>\n<p>If you read the library's source or documentation and learn that a handle may be used from one thread at a time but moved between threads, you can opt in deliberately with <code>unsafe impl Send for Kv {}</code> and a comment citing the evidence. If the library is fully thread-safe internally, you can add <code>unsafe impl Sync</code> too. Both are claims about code you did not write, so make them only with documentation or source in hand. If you want sharing without evidence, wrap the handle in a <code>Mutex&lt;Kv&gt;</code> in your application, and the mutex serializes access, which is safe for any library that tolerates use from different threads one at a time.</p>\n<h2>Use after drop</h2>\n<p>Since <code>Kv</code> owns the handle, destroying it takes the value away. The compiler does the bookkeeping that C leaves to the programmer:</p>\n<pre><code class=\"language-text\">error[E0382]: borrow of moved value: `kv`\n --&gt; examples/bad3.rs:6:20\n  |\n4 |     let kv = Kv::open(\"s\").unwrap();\n  |         -- move occurs because `kv` has type `Kv`, which does not implement the `Copy` trait\n5 |     drop(kv);\n  |          -- value moved here\n6 |     println!(\"{}\", kv.len());\n  |                    ^^ value borrowed here after move</code></pre>\n<p>Two lines of C do this without complaint and crash later. Here the second line is rejected, and the message names the move. Because <code>Kv</code> is not <code>Copy</code> or <code>Clone</code>, there cannot be two owners, and the handle is freed exactly once.</p>\n<h2>Offering both the borrowed and the owned view</h2>\n<p>The <code>entries</code> method added during testing shows a pattern worth adopting wherever a wrapper hands out pointers the C library owns. Keep the zero-copy accessor, <code>get</code>, for callers who read and move on, and add an owned snapshot, <code>entries</code>, for callers who need data to outlive the next mutation. The snapshot is built on the same <code>for_each</code> machinery, copying each key and value into a <code>String</code>, so it adds no new unsafe code. The doctest demonstrates the difference: after taking a snapshot, a later <code>set</code> is allowed, because the snapshot borrows nothing from <code>kv</code>. The borrow checker's refusal in the first error message turns into a documented escape hatch.</p>\n<p>Resist the urge to make the safe type clever. An iterator that yields borrowed pairs straight from the C list would be faster, and the C library gives no guarantee about the list's stability while you hold a pointer into it, so writing one means reading the C source to find out. The callback-based <code>for_each</code> is the access path the header documents, so build on that, and offer an iterator only if profiling shows the snapshot's allocations matter.</p>\n<h2>Mapping C error conventions</h2>\n<p>This C library signals failure with a single -1, so <code>Error::Failed</code> is enough. Real libraries are less kind, and the mapping is where wrappers earn their keep. A library that returns negative <code>errno</code> values becomes <code>std::io::Error::from_raw_os_error(-rc)</code>, which gives callers <code>kind()</code> and a readable message for free. A library with its own error codes becomes a <code>#[non_exhaustive]</code> enum with one variant per documented code, and an <code>Unknown(i32)</code> for codes added later, so a new library version does not turn into a panic in your <code>match</code>. A library with a thread-local \"last error\" function, like OpenSSL's, needs the message fetched immediately after the failing call, on the same thread, before any other call clobbers it; put the failing call and the fetch in one private helper so nobody can separate them. In every case the principle matches the rest of the guide: the C caller had to know the convention, and the Rust caller should not.</p>\n<h2>Putting a global initializer behind a constructor</h2>\n<p>Many C libraries need <code>lib_init()</code> before the first call and <code>lib_cleanup()</code> at exit. The first is easy to forget and the second is dangerous to call twice. Wrap the init in a <code>static INIT: std::sync::Once</code> that <code>Kv::open</code> runs through <code>INIT.call_once(...)</code>, so the first handle created initializes the library and later ones do nothing, from any thread. Skip the cleanup call unless the library demands it; process exit reclaims memory, and a cleanup that races with a handle still in use on another thread is a crash that only appears at shutdown, in production, once a month. If cleanup is mandatory, tie it to a reference-counted private guard that every handle holds, so it runs when the last handle drops and not before.</p>\n<h2>Test the wrapper, and what tests cannot see</h2>\n<p>The six unit tests and one documentation test in the file cover the behaviors that map to the header's promises. Set and overwrite checks that the second <code>set</code> of <code>host</code> replaces the first. A key containing a NUL byte becomes an error and not a crash. A closure captures local state. Non-ASCII text round-trips. The <code>entries</code> snapshot survives a later <code>set</code>, which is the copy-versus-borrow decision made visible. And two thousand open and close cycles run without a crash, a cheap smoke test for the <code>Drop</code> path. The doctest on <code>entries</code> doubles as the documentation example and as a test that the documented usage compiles. They all passed:</p>\n<pre><code class=\"language-text\">running 6 tests\ntest tests::entries_snapshot_survives_a_later_set ... ok\ntest tests::interior_nul_is_an_error_not_a_crash ... ok\ntest tests::for_each_can_capture_state ... ok\ntest tests::set_get_and_overwrite ... ok\ntest tests::unicode_round_trips ... ok\ntest tests::many_open_and_close_cycles_do_not_crash ... ok\ntest result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\n\nDoc-tests safekv\ntest src/lib.rs - Kv::entries (line 89) ... ok\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s</code></pre>\n<p>Tests have limits at this boundary. Miri cannot execute C code, so it will not check the foreign side. A use after free inside the C library, or a leak, needs a sanitizer: build the C file with <code>-fsanitize=address</code> and run the tests under it, or use <code>valgrind</code> on Linux. Run those on CI for the wrapper crate, since a wrapper that is sound on paper still depends on the C library behaving as documented. Include tests for the awkward inputs: empty strings, a very long value, keys that differ only in case, and the repeated open and close of many stores to expose leaks.</p>\n<h2>Conventions that make a binding crate pleasant</h2>\n<p>A few habits distinguish a binding you would depend on from one you would fork. Name the crate <code>foo</code> and its raw layer <code>foo-sys</code>, and pin the native library's version in the <code>-sys</code> crate's build script. Implement <code>Debug</code> for <code>Kv</code> without printing the pointer, or print the name only. Provide <code>Error</code> with <code>Display</code> and <code>std::error::Error</code>, as here, so it composes with <code>anyhow</code> and <code>thiserror</code>. Document every public method with a <code># Errors</code> section. Offer a <code>Result</code>-returning API rather than panics; panicking on out-of-memory is the choice of the standard library, not of a binding. And if the C library has global state, such as an initialization call, wrap it in a <code>std::sync::Once</code> inside <code>Kv::open</code> so the user cannot forget it.</p>\n<h2>When not to write a binding</h2>\n<p>If the library has a maintained Rust binding, use it and spend the time elsewhere; check crates.io for a <code>-sys</code> crate before writing declarations. If you call one function once, an <code>unsafe</code> block with a <code>SAFETY</code> comment at the call site is simpler than a crate. If the C library is so small that porting it is an afternoon's work, a Rust port removes the foreign code, the build step and the sanitizer runs. And if the library is C++, plain <code>bindgen</code> struggles with templates and exceptions, so reach for <code>cxx</code>. Everywhere else, the process in this guide, read the contract, own the handle, borrow the pointer, convert the errors, leave out the auto traits you cannot justify, is the path to a binding where the compiler does the reviewing. For the complementary direction, see <a href=\"/library/call-rust-from-c-cbindgen\">calling Rust from C with cbindgen</a>, and for the language rules underneath, <a href=\"/library/rust-safe-wrapper-unsafe\">writing a safe wrapper around unsafe code</a>.</p>\n<h2>A final review before publishing</h2>\n<p>Walk the public surface once with a hostile caller in mind. Can any safe sequence of calls reach a use after free, a double free or a data race? The compile errors above argue no for the three most common, and a reviewer should try to find a fourth. Is every <code>unsafe</code> block short, commented and local to one function? Does every public function either return a <code>Result</code> or cannot fail? Are the <code>Send</code> and <code>Sync</code> decisions written down in the type's documentation, with the evidence? If you can answer yes to all four, the crate is ready for someone else to build on.</p>","date_published":"2026-11-03T09:54:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/rust-ffi-ownership-free","url":"https://rusthacks.dev/library/rust-ffi-ownership-free","title":"Ownership Across FFI in Rust Who Frees the Memory Explained","summary":"Memory must be freed by the allocator that created it. At an FFI boundary that means every allocation travels with a matching free function on the same side.","content_html":"<p>Memory bugs at an FFI boundary are rarely about the languages. They are about nobody agreeing who is responsible. The rule that settles most questions is short: whoever allocates, frees, using the same allocator. Examples compile on Rust 1.90, edition 2024.</p>\n<h2>Why allocators must match</h2>\n<p>An allocator keeps hidden bookkeeping next to each block, and a <code>free</code> implementation expects its own format. Rust's default allocator on many platforms is the system <code>malloc</code>, but you can replace it with <code>jemalloc</code> or <code>mimalloc</code>, and on Windows the C runtime and the Rust runtime can each own a heap. Passing a pointer to the wrong <code>free</code> corrupts the heap, sometimes silently and much later (<a href=\"/library/rust-custom-global-allocator\">custom allocators</a>).</p>\n<h2>Rust gives C an owned value</h2>\n<pre><code class=\"language-rust\">use std::ffi::{c_char, CString};\n\npub struct Config {\n    pub retries: u32,\n}\n\n#[unsafe(no_mangle)]\npub extern \"C\" fn config_new() -&gt; *mut Config {\n    Box::into_raw(Box::new(Config { retries: 3 }))\n}\n\n#[unsafe(no_mangle)]\npub extern \"C\" fn config_free(p: *mut Config) {\n    if !p.is_null() {\n        // SAFETY: p came from config_new and has not been freed\n        drop(unsafe { Box::from_raw(p) });\n    }\n}\n\n#[unsafe(no_mangle)]\npub extern \"C\" fn config_describe(p: *const Config) -&gt; *mut c_char {\n    if p.is_null() {\n        return std::ptr::null_mut();\n    }\n    // SAFETY: p is non-null and points to a live Config per the API contract\n    let c = unsafe { &amp;*p };\n    match CString::new(format!(\"retries={}\", c.retries)) {\n        Ok(s) =&gt; s.into_raw(),\n        Err(_) =&gt; std::ptr::null_mut(),\n    }\n}\n\n#[unsafe(no_mangle)]\npub extern \"C\" fn string_free(s: *mut c_char) {\n    if !s.is_null() {\n        // SAFETY: s came from CString::into_raw in this library\n        drop(unsafe { CString::from_raw(s) });\n    }\n}</code></pre>\n<p>Each constructor has a destructor, and the string function names its partner in documentation. A C caller that calls <code>free(str)</code> instead of <code>string_free(str)</code> is the bug to prevent with comments and tests.</p>\n<h2>C gives Rust an owned value</h2>\n<pre><code class=\"language-rust\">use std::ffi::c_void;\n\nunsafe extern \"C\" {\n    fn make_buffer(len: usize) -&gt; *mut u8;   // returns malloc'd memory\n    fn free_buffer(p: *mut u8);               // the library's own free\n}\n\npub struct Buffer {\n    ptr: *mut u8,\n    len: usize,\n}\n\nimpl Buffer {\n    pub fn new(len: usize) -&gt; Option&lt;Self&gt; {\n        // SAFETY: plain call, returns null on failure\n        let ptr = unsafe { make_buffer(len) };\n        if ptr.is_null() { None } else { Some(Self { ptr, len }) }\n    }\n\n    pub fn as_slice(&amp;self) -&gt; &amp;[u8] {\n        // SAFETY: ptr is valid for len bytes until Drop\n        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }\n    }\n}\n\nimpl Drop for Buffer {\n    fn drop(&amp;mut self) {\n        // SAFETY: ptr came from make_buffer and is freed once\n        unsafe { free_buffer(self.ptr) };\n    }\n}</code></pre>\n<p>Never convert such a pointer into a <code>Vec</code> or <code>Box</code> with <code>from_raw_parts</code>. Rust would later free it with its allocator. Keep the foreign pointer in a type whose <code>Drop</code> calls the foreign free. Do not call <code>std::mem::forget</code> on guard types to \"hand off\" without recording who frees.</p>\n<h2>Borrowed pointers</h2>\n<p>A function like <code>const char *config_name(const config *)</code> returns a pointer into the object. Document it as borrowed, valid until the object is freed or modified. In Rust, wrap it as <code>&amp;'a CStr</code> tied to the owner's lifetime:</p>\n<pre><code class=\"language-rust\">pub fn name(&amp;self) -&gt; &amp;std::ffi::CStr {\n    // SAFETY: the library guarantees a NUL terminated string valid as long as self\n    unsafe { std::ffi::CStr::from_ptr(config_name(self.raw)) }\n}</code></pre>\n<p>The returned reference borrows <code>&amp;self</code>, so the compiler prevents it outliving the object.</p>\n<h2>Common mistakes</h2>\n<p>| Mistake | Result | |---|---| | <code>free()</code> on a <code>Box::into_raw</code> pointer | heap corruption | | <code>Box::from_raw</code> on a <code>malloc</code> pointer | allocator mismatch | | Double free (calling destroy twice) | undefined behavior | | Using a pointer after destroy | use after free | | Returning a pointer to a local variable | dangling | | Forgetting the free | leak, found by sanitizers |</p>\n<p>Use AddressSanitizer or Valgrind on the combined program, and Miri for the Rust half (<a href=\"/library/rust-sanitizers-unsafe\">sanitizers</a>).</p>\n<h2>Design checklist</h2>\n<ol><li>Name every pointer parameter owned or borrowed in the docs.</li><li>Keep constructor and destructor adjacent in the header.</li><li>Offer opaque handles, not structs with owned fields.</li><li>In Rust wrappers, put the free in <code>Drop</code>.</li></ol>\n<p>Next steps: <a href=\"/library/rust-ffi-callbacks\">callbacks</a>, <a href=\"/library/rust-panic-across-ffi\">panics across FFI</a> and <a href=\"/library/call-rust-from-c-cbindgen\">cbindgen exports</a>.</p>","date_published":"2026-11-03T09:50:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/call-rust-from-c-cbindgen","url":"https://rusthacks.dev/library/call-rust-from-c-cbindgen","title":"Calling Rust From C With Cbindgen and Staticlib Step by Step","summary":"To expose Rust to C, mark functions extern \"C\" with a stable name, build a staticlib or cdylib and let cbindgen write the header. The rules are about layout, ownership and panics.","content_html":"<p>The usual direction of FFI talk is Rust calling C, but the reverse is how Rust gets adopted in the real world. A team does not rewrite a 400,000 line C application. It replaces one hot or hazardous module, a parser that keeps producing security advisories, a compression routine, a protocol decoder, with Rust code that the rest of the application calls through its existing C interface. The C side sees a header and a library. The Rust side keeps its type system, its tests and its borrow checker everywhere except the thin layer at the border.</p>\n<p>This guide builds that border. It exports a small word-counting library from Rust, generates the C header with <code>cbindgen</code>, compiles a C program against it and runs it. It also covers the rules the border must enforce: no panic may escape, memory must be freed by the side that allocated it, and errors must become integers. The library was built with Rust 1.99 (edition 2024) and <code>cbindgen</code> 0.29.4, the C program was compiled with the system <code>cc</code> on an Apple silicon Mac, and the outputs below are what they printed.</p>\n<h2>What the C side needs from you</h2>\n<p>C has no generics, no ownership and no exceptions. Whatever you export must be expressible in those terms, which constrains the design more than any syntax does. Five rules cover it.</p>\n<ol><li>Functions use the C calling convention and a stable, unmangled name: <code>extern \"C\"</code> plus <code>#[unsafe(no_mangle)]</code>.</li><li>Types that cross are either primitives, pointers, or <code>#[repr(C)]</code> structs whose layout C can reproduce. Everything else, a <code>String</code>, a <code>Vec</code>, a trait object, stays on the Rust side behind an opaque pointer.</li><li>Every object Rust creates is destroyed by a Rust function you export. C must never <code>free</code> it.</li><li>Failure is a return code or a null pointer. Nothing unwinds across the border.</li><li>Every function documents what it requires of its pointers, because C callers cannot be type-checked.</li></ol>\n<p>The library below follows them one at a time.</p>\n<h2>The Rust library</h2>\n<p>Start with the manifest. The crate type decides what C links against: <code>staticlib</code> produces a <code>.a</code> archive that is linked into the final executable, and <code>cdylib</code> produces a shared library (<code>.so</code>, <code>.dylib</code> or <code>.dll</code>). Build both and ship whichever your C build prefers.</p>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[package]\nname = \"wordstat\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"staticlib\", \"cdylib\"]</code></pre>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">//! A word counter with a C API.\n\nuse std::ffi::{c_char, c_int, CStr, CString};\nuse std::panic::{catch_unwind, AssertUnwindSafe};\n\n/// Opaque to C: it only ever sees a `WordStats *`.\npub struct WordStats {\n    words: usize,\n    lines: usize,\n}\n\n/// Plain data that C can receive by value.\n#[repr(C)]\npub struct WordStatsSummary {\n    pub words: usize,\n    pub lines: usize,\n}\n\npub const WORDSTAT_OK: c_int = 0;\npub const WORDSTAT_NULL_ARGUMENT: c_int = -1;\npub const WORDSTAT_INVALID_UTF8: c_int = -2;\npub const WORDSTAT_PANIC: c_int = -3;\n\n/// Create a counter. Free it with `wordstat_free`.\n#[unsafe(no_mangle)]\npub extern \"C\" fn wordstat_new() -&gt; *mut WordStats {\n    Box::into_raw(Box::new(WordStats { words: 0, lines: 0 }))\n}\n\n/// Add the words and lines in a NUL-terminated UTF-8 string.\n///\n/// # Safety\n/// `stats` must come from `wordstat_new` and not have been freed. `text` must point to a\n/// NUL-terminated string that stays valid for the duration of the call.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wordstat_feed(stats: *mut WordStats, text: *const c_char) -&gt; c_int {\n    if stats.is_null() || text.is_null() {\n        return WORDSTAT_NULL_ARGUMENT;\n    }\n    let result = catch_unwind(AssertUnwindSafe(|| {\n        // SAFETY: both pointers were checked for null; validity is the caller's contract.\n        let (stats, text) = unsafe { (&amp;mut *stats, CStr::from_ptr(text)) };\n        match text.to_str() {\n            Ok(s) =&gt; {\n                stats.words += s.split_whitespace().count();\n                stats.lines += s.lines().count();\n                WORDSTAT_OK\n            }\n            Err(_) =&gt; WORDSTAT_INVALID_UTF8,\n        }\n    }));\n    result.unwrap_or(WORDSTAT_PANIC)\n}\n\n/// Totals so far. Returns zeros for a null pointer.\n///\n/// # Safety\n/// `stats` must be null or a live pointer from `wordstat_new`.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wordstat_summary(stats: *const WordStats) -&gt; WordStatsSummary {\n    match unsafe { stats.as_ref() } {\n        Some(s) =&gt; WordStatsSummary { words: s.words, lines: s.lines },\n        None =&gt; WordStatsSummary { words: 0, lines: 0 },\n    }\n}\n\n/// A human-readable report. The returned string is allocated by Rust and must be\n/// released with `wordstat_string_free`, never with C's `free`.\n///\n/// # Safety\n/// `stats` must be null or a live pointer from `wordstat_new`.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wordstat_report(stats: *const WordStats) -&gt; *mut c_char {\n    let Some(s) = (unsafe { stats.as_ref() }) else { return std::ptr::null_mut() };\n    let text = format!(\"{} words on {} lines\", s.words, s.lines);\n    CString::new(text).map_or(std::ptr::null_mut(), CString::into_raw)\n}\n\n/// # Safety\n/// `s` must be null or a pointer returned by `wordstat_report`, not yet freed.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wordstat_string_free(s: *mut c_char) {\n    if !s.is_null() {\n        drop(unsafe { CString::from_raw(s) });\n    }\n}\n\n/// # Safety\n/// `stats` must be null or a pointer from `wordstat_new`, not yet freed.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wordstat_free(stats: *mut WordStats) {\n    if !stats.is_null() {\n        drop(unsafe { Box::from_raw(stats) });\n    }\n}\n\n/// Write the report into a buffer the caller owns, `snprintf` style.\n///\n/// Returns the length the full report needs, not counting the NUL, or a negative error code.\n/// At most `cap` bytes are written (including the NUL), so a return value `&gt;= cap` means\n/// the output was truncated. Passing `buf = NULL` with `cap = 0` asks only for the length.\n///\n/// # Safety\n/// `stats` must be a live pointer from `wordstat_new`. `buf` must be null or valid for\n/// writes of `cap` bytes.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wordstat_report_into(stats: *const WordStats, buf: *mut c_char, cap: usize) -&gt; isize {\n    let Some(s) = (unsafe { stats.as_ref() }) else { return WORDSTAT_NULL_ARGUMENT as isize };\n    let text = format!(\"{} words on {} lines\", s.words, s.lines);\n    if !buf.is_null() &amp;&amp; cap &gt; 0 {\n        let n = text.len().min(cap - 1);\n        // SAFETY: the caller promised `buf` is writable for `cap` bytes and `n + 1 &lt;= cap`.\n        unsafe {\n            std::ptr::copy_nonoverlapping(text.as_ptr(), buf.cast::&lt;u8&gt;(), n);\n            *buf.add(n) = 0;\n        }\n    }\n    text.len() as isize\n}</code></pre>\n<p>Read the file for the pattern, because the same shape repeats for every Rust library you expose.</p>\n<p>An opaque handle. <code>WordStats</code> is an ordinary Rust struct with no <code>#[repr(C)]</code>. C never sees its fields, only a pointer to it. <code>wordstat_new</code> boxes one and returns <code>Box::into_raw</code>, which hands ownership to the caller as a raw pointer. <code>wordstat_free</code> takes that pointer back with <code>Box::from_raw</code> and drops it. The pair is the lifecycle, and there are no other ways in or out. Because the struct is opaque, you can add and reorder fields in the next release without recompiling a single C file.</p>\n<p>Data by value. <code>WordStatsSummary</code> is <code>#[repr(C)]</code>, which makes its layout match what a C compiler would produce for the same two fields, so it can be returned by value. Use this for small plain-data results. It is the one place where the layout becomes part of your public interface: adding a field later breaks every compiled caller, so such structs deserve more care than opaque handles.</p>\n<p>Error codes. The four constants, <code>WORDSTAT_OK</code> and three negative values, are the whole error vocabulary. <code>wordstat_feed</code> checks its pointers for null before touching them, converts the C string with <code>CStr::to_str</code>, which fails for text that is not UTF-8, and returns a distinct code for each case. C callers can <code>switch</code> on the result. Do not return Rust's <code>Result</code>: it has no stable layout.</p>\n<p>Panic containment. The body of <code>wordstat_feed</code> runs inside <code>catch_unwind</code>, and a panic becomes <code>WORDSTAT_PANIC</code> instead of unwinding into C. This matters more than it looks. Unwinding through frames compiled by a C compiler is undefined behavior in the older rules, and an abort in current Rust, which kills the host application. A library that crashes its host over a bug in one call is a library no one will embed twice. Wrap every exported function that can panic, which in practice means every function that does more than trivial work.</p>\n<p>A string that Rust allocated. <code>wordstat_report</code> builds a <code>String</code>, converts it to a <code>CString</code> and returns <code>into_raw</code>, handing C a <code>char *</code>. The doc comment says what the type system cannot: release it with <code>wordstat_string_free</code>, never with <code>free</code>. The returned pointer was allocated by Rust's allocator, and the matching free must go through the same allocator. On many platforms Rust uses the system <code>malloc</code>, and calling <code>free</code> happens to work, which is the worst possible outcome because it teaches the wrong lesson and then fails when someone switches to a different allocator, or on Windows when the library and the application are linked against different C runtimes.</p>\n<h2>Generate the header</h2>\n<p>Writing the header by hand is how the C and Rust sides drift apart. <code>cbindgen</code> reads the crate's source and emits it. A small configuration file sets the language and the include guard:</p>\n<pre data-filename=\"cbindgen.toml\"><code class=\"language-toml\">language = \"C\"\ninclude_guard = \"WORDSTAT_H\"\ncpp_compat = true\ndocumentation = true\ndocumentation_style = \"c\"</code></pre>\n<pre><code class=\"language-bash\">cbindgen --config cbindgen.toml --crate wordstat -o wordstat.h</code></pre>\n<p>Here is what it produced, trimmed to the declarations:</p>\n<pre data-filename=\"wordstat.h\"><code class=\"language-c\">#ifndef WORDSTAT_H\n#define WORDSTAT_H\n\n#define WORDSTAT_OK 0\n#define WORDSTAT_NULL_ARGUMENT -1\n#define WORDSTAT_INVALID_UTF8 -2\n#define WORDSTAT_PANIC -3\n\n/* Opaque to C: it only ever sees a `WordStats *`. */\ntypedef struct WordStats WordStats;\n\n/* Plain data that C can receive by value. */\ntypedef struct WordStatsSummary {\n  uintptr_t words;\n  uintptr_t lines;\n} WordStatsSummary;\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif // __cplusplus\n\nstruct WordStats *wordstat_new(void);\nint wordstat_feed(struct WordStats *stats, const char *text);\nstruct WordStatsSummary wordstat_summary(const struct WordStats *stats);\nchar *wordstat_report(const struct WordStats *stats);\nvoid wordstat_string_free(char *s);\nvoid wordstat_free(struct WordStats *stats);\n\n#ifdef __cplusplus\n}  // extern \"C\"\n#endif  // __cplusplus\n\n#endif  /* WORDSTAT_H */</code></pre>\n<p>The doc comments, including the <code># Safety</code> sections, were carried across as C comments, so the contract travels with the header. The opaque struct became a forward-declared <code>typedef struct WordStats WordStats</code>, which C code can hold pointers to but cannot instantiate. <code>usize</code> became <code>uintptr_t</code>, and the constants became macros. <code>cpp_compat</code> wrapped everything in <code>extern \"C\"</code> so C++ code can include the same file.</p>\n<p>One naming lesson comes from this output. The first version of this library named its types <code>Stats</code> and <code>Summary</code>, and the generated header declared global C types with those names, which collide with any application that has its own <code>Stats</code>. C has no namespaces, so prefix every exported name. The functions already carried <code>wordstat_</code>, and renaming the types to <code>WordStats</code> and <code>WordStatsSummary</code> fixed the types. The <code>[export] prefix</code> option in the configuration can do it automatically.</p>\n<h2>The C program</h2>\n<pre data-filename=\"main.c\"><code class=\"language-c\">#include &lt;stdio.h&gt;\n#include &lt;stdlib.h&gt;\n#include \"wordstat.h\"\n\nint main(void) {\n    WordStats *stats = wordstat_new();\n\n    const char *docs[] = {\"the quick brown fox\", \"jumps over\\nthe lazy dog\"};\n    for (int i = 0; i &lt; 2; i++) {\n        int rc = wordstat_feed(stats, docs[i]);\n        if (rc != WORDSTAT_OK) {\n            fprintf(stderr, \"feed failed: %d\\n\", rc);\n            return 1;\n        }\n    }\n\n    WordStatsSummary s = wordstat_summary(stats);\n    printf(\"summary: %zu words, %zu lines\\n\", s.words, s.lines);\n\n    char *report = wordstat_report(stats);\n    printf(\"report:  %s\\n\", report);\n    wordstat_string_free(report); /* not free(): Rust allocated this */\n\n    /* Errors are return codes, not crashes. */\n    printf(\"feed(NULL text) -&gt; %d\\n\", wordstat_feed(stats, NULL));\n    printf(\"feed(bad utf-8) -&gt; %d\\n\", wordstat_feed(stats, \"caf\\xff\"));\n\n    wordstat_free(stats);\n    wordstat_free(NULL); /* allowed */\n    return 0;\n}</code></pre>\n<p>Build the Rust library in release mode, then compile and link the C program against the static archive. The Rust standard library needs a few system libraries, and <code>rustc</code> will tell you which:</p>\n<pre><code class=\"language-bash\">cargo build --release\ncargo rustc --release --crate-type staticlib -- --print native-static-libs\ncc -Wall -Wextra -o demo main.c target/release/libwordstat.a -lSystem -lc -lm\n./demo</code></pre>\n<p>The second command prints the line <code>note: native-static-libs: -lSystem -lc -lm</code> on this machine; on Linux it lists <code>-lgcc_s -lutil -lrt -lpthread -lm -ldl -lc</code>. Copy exactly what it prints into your link command. Missing a library produces undefined-symbol errors from <code>std</code> internals that look unrelated to your code.</p>\n<pre><code class=\"language-text\">summary: 9 words, 3 lines\nreport:  9 words on 3 lines\nfeed(NULL text) -&gt; -1\nfeed(bad utf-8) -&gt; -2</code></pre>\n<p>The first document has four words on one line and the second has five words across two, so nine words and three lines, computed by Rust and printed by C. The next two lines show the border holding. Passing a null text pointer returned -1 where a careless C library would have crashed, and passing the bytes <code>caf\\xff</code>, which end in a byte that is not valid UTF-8, returned -2 rather than corrupting the counter. <code>wordstat_free(NULL)</code> is explicitly allowed, mirroring <code>free(NULL)</code>, which makes cleanup code in C simpler to write correctly.</p>\n<h2>Static or dynamic</h2>\n<p>The same build produced both artifacts. The static archive <code>libwordstat.a</code> is 18.7 MB on disk, large because it carries debugging information and all of <code>std</code>, and the linker keeps only what the program uses, so the final executable is much smaller. The dynamic library <code>libwordstat.dylib</code> is 388 KB. Its exported symbols show exactly the six functions in the header and nothing else:</p>\n<pre><code class=\"language-text\">$ nm -gU target/release/libwordstat.dylib | grep wordstat\n0000000000000900 T _wordstat_feed\n0000000000000c2c T _wordstat_free\n0000000000000c40 T _wordstat_new\n0000000000000c78 T _wordstat_report\n0000000000000d6c T _wordstat_string_free\n0000000000000d9c T _wordstat_summary</code></pre>\n<p>That is the stable surface. Anything not marked <code>#[unsafe(no_mangle)]</code> is invisible to C. Check this listing in CI so an accidental export, or a missing one, fails a build. Choose static linking for a single self-contained executable and the simplest deployment; choose the dynamic library when several programs share it, when you want to update the Rust side without relinking, or when the host is a language runtime that loads plugins. With a dynamic library the loader must find it at runtime, via <code>LD_LIBRARY_PATH</code> or <code>DYLD_LIBRARY_PATH</code> while testing, and an install name or rpath in production.</p>\n<h2>Let the caller own the buffer</h2>\n<p>The report function above returns memory that Rust allocated, which forces the two-sided lifecycle: C must remember to call <code>wordstat_string_free</code>. A cleaner border avoids shared ownership entirely by letting the caller supply the memory, the way <code>snprintf</code> does. The caller passes a buffer and its capacity, Rust writes into it, and the return value says how much space the full result needs. Nothing allocated on one side is freed on the other.</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wordstat_report_into(stats: *const WordStats, buf: *mut c_char, cap: usize) -&gt; isize {\n    let Some(s) = (unsafe { stats.as_ref() }) else { return WORDSTAT_NULL_ARGUMENT as isize };\n    let text = format!(\"{} words on {} lines\", s.words, s.lines);\n    if !buf.is_null() &amp;&amp; cap &gt; 0 {\n        let n = text.len().min(cap - 1);\n        // SAFETY: the caller promised `buf` is writable for `cap` bytes and `n + 1 &lt;= cap`.\n        unsafe {\n            std::ptr::copy_nonoverlapping(text.as_ptr(), buf.cast::&lt;u8&gt;(), n);\n            *buf.add(n) = 0;\n        }\n    }\n    text.len() as isize\n}</code></pre>\n<p>(The doc comment, trimmed here, spells out the contract: at most <code>cap</code> bytes are written including the terminator, the return value is the full length excluding it, and a <code>NULL</code> buffer with zero capacity asks only for the length.) The C caller uses the standard two-call pattern, and the second call shows truncation:</p>\n<pre data-filename=\"main2.c\"><code class=\"language-c\">ptrdiff_t need = wordstat_report_into(stats, NULL, 0);\nchar *buf = malloc((size_t)need + 1);\nwordstat_report_into(stats, buf, (size_t)need + 1);\nprintf(\"needed %td bytes: \\\"%s\\\"\\n\", need, buf);\nfree(buf); /* correct here: C allocated it, so C frees it */\n\nchar small[8];\nptrdiff_t full = wordstat_report_into(stats, small, sizeof small);\nprintf(\"small buffer holds \\\"%s\\\", full length is %td (truncated: %s)\\n\",\n       small, full, (size_t)full &gt;= sizeof small ? \"yes\" : \"no\");</code></pre>\n<pre><code class=\"language-text\">needed 18 bytes: \"7 words on 1 lines\"\nsmall buffer holds \"7 words\", full length is 18 (truncated: yes)</code></pre>\n<p>Here <code>free(buf)</code> is correct, because C allocated the buffer. The eight-byte buffer received \"7 words\", seven characters plus the terminator, and the return value 18 told the caller the output was cut off. The length written is <code>min(len, cap - 1)</code>, so the function can never write past the buffer, which is the property a C caller most needs from a library it did not write. This pattern is the better default for any variable-length output, and the library gains a second benefit: there is nothing to leak when the C caller returns early on an error path. Reserve the allocate-and-return style for objects with real lifecycles, like the <code>WordStats</code> handle itself.</p>\n<h2>Evolving the interface without breaking callers</h2>\n<p>A shared library lives longer than any one release of the program that uses it, so the interface needs a versioning plan on day one. Three habits cost almost nothing. Export a function <code>wordstat_version()</code> that returns a number or a string, so a program can check at startup that it linked the library it was compiled against. Never remove or change the meaning of an exported function in a compatible release; add <code>wordstat_feed2</code> and keep the old name. And keep structs opaque whenever possible. The one by-value struct in this library is also the one place where a future change is a breaking change, so if you expect to add statistics, return them through accessor functions, <code>wordstat_word_count(stats)</code> and so on, and keep the layout private. A change to the header should be a conscious decision with a version bump, which the CI check that regenerates the header and compares it to the committed one is there to enforce.</p>\n<p>Symbols deserve the same discipline. On Linux, a version script or <code>-Wl,--exclude-libs</code> keeps Rust's internal symbols from leaking out of the shared object, and on every platform the <code>nm -gU</code> listing above is the audit: the exported set should equal the header, exactly. If it lists more, something has become part of your public interface by accident.</p>\n<h2>How the border breaks</h2>\n<p>The failures that matter are all things the compiler cannot see, so list them before you ship.</p>\n<p>Freeing across allocators. Calling <code>free()</code> on the pointer from <code>wordstat_report</code> happened to run without a crash on this platform, because Rust's default allocator here is the system <code>malloc</code>. That is luck. A project that later switches its Rust code to another global allocator, or a Windows build where the DLL and the executable use different C runtimes, will crash or corrupt the heap. The rule is absolute: whoever allocates, frees.</p>\n<p>Use after free and double free. C can call <code>wordstat_free</code> twice on the same pointer. The Rust side cannot stop it, because after <code>Box::from_raw</code> it holds no state. If the risk is real, expose handles that are indices into a table, or document it loudly and test with a sanitizer.</p>\n<p>Threads. The example is not thread-safe: two C threads feeding one <code>WordStats</code> race. Rust's <code>Send</code> and <code>Sync</code> are meaningless to C. Either document \"one thread per handle\", or put a <code>Mutex</code> inside and make the handle safe to share.</p>\n<p>Layout drift. A <code>#[repr(C)]</code> struct returned by value is part of your binary interface. Adding a field changes its size, and old compiled callers read garbage. Version the library, or pass such structs through opaque accessor functions instead.</p>\n<p>Strings that are not UTF-8. C strings are arbitrary bytes. The example rejects invalid UTF-8 with an error code; a library that must accept any bytes should take a pointer and a length (<code>*const u8</code>, <code>usize</code>) and work on <code>&amp;[u8]</code>, which also removes the NUL-terminator requirement and allows embedded NULs.</p>\n<h2>A review checklist for every exported function</h2>\n<p>Before an exported function is merged, a reviewer should be able to answer yes to each of these, because each one is a way a C caller can hurt the process through a function that looks harmless in Rust.</p>\n<ul><li>Does it start with null checks on every pointer argument, and return an error code for a null that should not be one?</li><li>Is the body inside <code>catch_unwind</code>, so a bug becomes a return value and not an abort of the host?</li><li>Does the doc comment carry a <code># Safety</code> section naming the lifetime, validity and thread requirements of each pointer?</li><li>Is every allocation either freed by an exported function in the same library, or avoided by letting the caller own the memory?</li><li>Does it avoid returning a Rust type with no stable layout: <code>String</code>, <code>Vec</code>, <code>Option</code>, <code>Result</code>, a trait object, a tuple?</li><li>Is it covered by a C test that ran under a sanitizer, including the error paths and a call with <code>NULL</code>?</li><li>Is the symbol name prefixed, so it cannot collide with the host application's own names?</li></ul>\n<p>A function that passes all seven is boring, and boring is the aim. The adventurous parts of the program, the parsing, the data structures, the concurrency, stay on the Rust side of the line, where the compiler is checking them.</p>\n<h2>Testing from both sides</h2>\n<p>Test the Rust logic with ordinary <code>cargo test</code>, and test the border from C. A tiny C test program built in CI and run under <code>valgrind</code> or AddressSanitizer exercises the lifecycle that Rust tests cannot see: create, feed, report, free, and the error paths. Run the same C program against the static and the dynamic build. And run <code>cbindgen</code> in the pipeline with a check that the committed header matches the generated one, so a changed signature in Rust cannot ship without the header changing alongside it.</p>\n<h2>When this is the wrong approach</h2>\n<p>If the C application can host a different process, an out-of-process service reached over a socket or a pipe removes the whole border, at the cost of a protocol. If the C++ side wants to use rich Rust types directly, the <code>cxx</code> crate generates safe bindings in both directions, instead of flattening everything to C. If the consumer is Python, Node or Java, use the dedicated binding crates (<a href=\"/library/python-extensions-pyo3-maturin\">PyO3</a> and <a href=\"/library/node-addons-napi-rs\">napi-rs</a>), which handle conversions and error translation for you. Use the <code>cbindgen</code> route when the consumer is C, or any language whose FFI speaks C, and when you want one narrow, stable, auditable surface. For the opposite direction, see <a href=\"/library/call-c-from-rust-bindgen\">calling C from Rust with extern and bindgen</a>.</p>","date_published":"2026-11-03T09:46:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/call-c-from-rust-bindgen","url":"https://rusthacks.dev/library/call-c-from-rust-bindgen","title":"Calling C From Rust With Extern and Bindgen Step by Step","summary":"Rust calls C through extern declarations that you write or generate. bindgen reads a header and produces them, leaving you to wrap the unsafe calls in a safe API.","content_html":"<p>Rust does not need to rewrite the world. Operating systems, databases, codecs, GPU drivers and decades of numerical code are written in C, and a Rust program can call all of it directly, with no runtime and no overhead beyond the call itself. The mechanism is the foreign function interface, and the cost is that every call crosses a boundary the compiler cannot see through. This guide shows how to cross it deliberately: first by hand, so you understand what is being promised, then with <code>bindgen</code>, which writes the declarations for you.</p>\n<p>The running example is a small C library, a key-value store with an opaque handle, string arguments, a returned pointer you must not free, and a callback. It is deliberately the same shape as the C libraries you will meet in practice. It was built with <code>cc</code> 1.11.1 and <code>bindgen</code> 0.73.2 on Rust 1.99, edition 2024, on an Apple silicon Mac, and the outputs below are what it printed.</p>\n<h2>The C library</h2>\n<pre data-filename=\"csrc/kv.h\"><code class=\"language-c\">#ifndef KV_H\n#define KV_H\n#include &lt;stddef.h&gt;\n\ntypedef struct kv kv_t;\n\n/* Create an empty store. Returns NULL if memory runs out. */\nkv_t *kv_open(const char *name);\n\n/* Copy key and value into the store. Returns 0 on success, -1 on bad arguments or no memory. */\nint kv_set(kv_t *store, const char *key, const char *value);\n\n/* Returns a pointer owned by the store, valid until the next kv_set of that key or kv_close.\n   Returns NULL if the key is absent. */\nconst char *kv_get(const kv_t *store, const char *key);\n\nsize_t kv_len(const kv_t *store);\n\ntypedef void (*kv_visit_fn)(const char *key, const char *value, void *user_data);\n\n/* Call visit once per entry, in insertion order. */\nvoid kv_foreach(const kv_t *store, kv_visit_fn visit, void *user_data);\n\n/* Free the store and everything in it. Passing NULL is allowed. */\nvoid kv_close(kv_t *store);\n\n#endif</code></pre>\n<p>Read the header the way you will read every C header you bind: for ownership. <code>kv_open</code> returns a handle you must eventually pass to <code>kv_close</code>. <code>kv_set</code> copies its arguments, so the caller keeps ownership of what it passed. <code>kv_get</code> returns a pointer the store owns, valid only until the next <code>kv_set</code> of the same key or until <code>kv_close</code>. <code>kv_foreach</code> takes a function pointer and an opaque <code>void *user_data</code> that it passes back untouched. None of those rules is visible in a C type signature. They live in comments, and your Rust has to honor them.</p>\n<p>The implementation, <code>csrc/kv.c</code>, is a hundred lines of linked-list code. It is not reproduced here, because the point of FFI is that you do not need to read it. The build compiles it with the <code>cc</code> crate in a build script:</p>\n<pre data-filename=\"build.rs\"><code class=\"language-rust\">fn main() {\n    cc::Build::new().file(\"csrc/kv.c\").include(\"csrc\").warnings(true).compile(\"kv\");\n    println!(\"cargo:rerun-if-changed=csrc/kv.c\");\n    println!(\"cargo:rerun-if-changed=csrc/kv.h\");\n}</code></pre>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[build-dependencies]\ncc = \"1\"</code></pre>\n<p><code>cc</code> finds the platform's C compiler, compiles the file, archives it into a static library named <code>libkv.a</code>, and tells Cargo to link it into your crate. That is all the build setup a vendored C source file needs. For a library already installed on the system, you would instead emit <code>cargo:rustc-link-lib=kv</code> and a search path, usually via the <code>pkg-config</code> crate.</p>\n<h2>Declaring the C API by hand</h2>\n<p>Before using a generator, write the declarations yourself once. It takes five minutes and teaches you what the generator is promising for you.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::ffi::{c_char, c_int, c_void, CStr, CString};\n\n// A hand-written declaration of the C API. Each line is a promise that the C side matches.\n#[repr(C)]\npub struct kv_t {\n    _private: [u8; 0], // an opaque type: Rust never looks inside\n}\n\nunsafe extern \"C\" {\n    fn kv_open(name: *const c_char) -&gt; *mut kv_t;\n    fn kv_set(store: *mut kv_t, key: *const c_char, value: *const c_char) -&gt; c_int;\n    fn kv_get(store: *const kv_t, key: *const c_char) -&gt; *const c_char;\n    fn kv_len(store: *const kv_t) -&gt; usize;\n    fn kv_foreach(store: *const kv_t, visit: extern \"C\" fn(*const c_char, *const c_char, *mut c_void), user: *mut c_void);\n    fn kv_close(store: *mut kv_t);\n}\n\nextern \"C\" fn print_entry(key: *const c_char, value: *const c_char, _user: *mut c_void) {\n    let (k, v) = unsafe { (CStr::from_ptr(key), CStr::from_ptr(value)) };\n    println!(\"  {} = {}\", k.to_string_lossy(), v.to_string_lossy());\n}\n\nfn main() {\n    let name = CString::new(\"settings\").unwrap();\n    let store = unsafe { kv_open(name.as_ptr()) };\n    assert!(!store.is_null());\n\n    for (k, v) in [(\"host\", \"example.test\"), (\"port\", \"8080\"), (\"host\", \"db.example.test\")] {\n        let (k, v) = (CString::new(k).unwrap(), CString::new(v).unwrap());\n        let rc = unsafe { kv_set(store, k.as_ptr(), v.as_ptr()) };\n        assert_eq!(rc, 0);\n    }\n\n    println!(\"{} entries\", unsafe { kv_len(store) });\n    let key = CString::new(\"host\").unwrap();\n    let value = unsafe { kv_get(store, key.as_ptr()) };\n    println!(\"host -&gt; {}\", unsafe { CStr::from_ptr(value) }.to_str().unwrap());\n\n    let missing = CString::new(\"nope\").unwrap();\n    println!(\"missing is null: {}\", unsafe { kv_get(store, missing.as_ptr()) }.is_null());\n\n    println!(\"all entries:\");\n    unsafe { kv_foreach(store, print_entry, std::ptr::null_mut()) };\n\n    unsafe { kv_close(store) };\n}</code></pre>\n<pre><code class=\"language-text\">2 entries\nhost -&gt; db.example.test\nmissing is null: true\nall entries:\n  host = db.example.test\n  port = 8080</code></pre>\n<p>The store ends with two entries even though three <code>kv_set</code> calls ran, because the third set <code>host</code> a second time and the C code replaced the value, so <code>host</code> now maps to <code>db.example.test</code>. The insertion order is preserved, <code>host</code> before <code>port</code>, as the header promised. Every piece of the file is a decision worth understanding.</p>\n<h3>The extern block and the types</h3>\n<p><code>unsafe extern \"C\" { ... }</code> declares functions that exist elsewhere and follow the C calling convention. In edition 2024 the <code>unsafe</code> before <code>extern</code> is mandatory, and it means something: you assert that every declaration in the block is accurate. The compiler cannot check it. If you declare <code>kv_len</code> as returning <code>u32</code> while C returns <code>size_t</code>, the program compiles and reads half a register. Omit the keyword and the compiler refuses:</p>\n<pre><code class=\"language-text\">error: extern blocks must be unsafe\n --&gt; src/main.rs:9:1\n   |\n 9 |   extern \"C\" {\n   |   ^\n   |   |\n   |  _help: needs `unsafe` before the extern keyword: `unsafe`</code></pre>\n<p>Types map to the <code>std::ffi</code> aliases rather than to Rust's fixed-width types, because C's <code>int</code> and <code>char</code> vary by platform. This table is the one to keep:</p>\n<table><thead><tr><th>C type</th><th>Rust type</th></tr></thead><tbody><tr><td><code>int</code>, <code>unsigned int</code></td><td><code>c_int</code>, <code>c_uint</code></td></tr><tr><td><code>char</code>, <code>signed char</code>, <code>unsigned char</code></td><td><code>c_char</code>, <code>c_schar</code>, <code>c_uchar</code></td></tr><tr><td><code>long</code>, <code>size_t</code>, <code>ssize_t</code></td><td><code>c_long</code>, <code>usize</code>, <code>isize</code></td></tr><tr><td><code>float</code>, <code>double</code></td><td><code>f32</code>, <code>f64</code></td></tr><tr><td><code>void *</code></td><td><code>*mut c_void</code></td></tr><tr><td><code>const char *</code></td><td><code>*const c_char</code></td></tr><tr><td><code>struct foo *</code> where foo is opaque</td><td><code>*mut Foo</code> with <code>#[repr(C)] struct Foo { _private: [u8; 0] }</code></td></tr></tbody></table>\n<p>The opaque type deserves its own sentence. <code>kv_t</code> is declared in the header but its fields are private to the C file, so Rust must never see its size or layout. A zero-length array in a <code>#[repr(C)]</code> struct is the idiom: you can name the type and hold pointers to it, but you cannot construct one or take its size. That prevents accidentally copying the handle by value, which would be undefined behavior.</p>\n<h3>Strings: the part everyone gets wrong</h3>\n<p>A Rust <code>&amp;str</code> is a pointer and a length, with no terminator. A C string is a pointer to bytes ending in a NUL. They are not interchangeable, and passing <code>\"host\".as_ptr()</code> to C compiles and reads past the end of the string looking for a NUL.</p>\n<p>The tool is <code>CString</code>, which allocates a copy with a terminator. Creating one can fail if the text contains a NUL byte of its own, since C would read it as the end of the string. <code>CString::new</code> returns a <code>Result</code> for that reason:</p>\n<pre><code class=\"language-text\">nul byte found in provided data at position: 4\nnul at byte 4</code></pre>\n<p>That is the output for <code>CString::new(\"sess\\0ion\")</code>. In FFI code, <code>unwrap()</code> on that call is a bug waiting for user input; map the error into your own error type. The reverse direction uses <code>CStr::from_ptr</code>, which measures a C string up to its NUL and borrows it as <code>&amp;CStr</code>. From there <code>to_str()</code> returns a <code>Result</code> because C text need not be UTF-8, and <code>to_string_lossy()</code> always succeeds by substituting replacement characters. Choose the one that matches what you want to do with a malformed byte.</p>\n<p>Mind the lifetimes, because the compiler will not. <code>key.as_ptr()</code> is valid only while the <code>CString</code> lives. Writing <code>CString::new(\"k\").unwrap().as_ptr()</code> creates the <code>CString</code>, takes a pointer into it, and drops it at the end of the statement, leaving a dangling pointer. Bind the <code>CString</code> to a variable and keep it alive across the call, as the example does.</p>\n<h3>Ownership of the returned pointer</h3>\n<p><code>kv_get</code> returns a <code>const char *</code> that the store owns. The Rust code reads it immediately inside the same expression and never frees it. Two mistakes follow from getting this wrong. Calling Rust's deallocator on a pointer from C's <code>malloc</code> corrupts the heap, because they can be different allocators. And holding the pointer across a later <code>kv_set</code> of the same key is a use after free, since the C code frees the old value string when it overwrites it. A comment in the header documented that rule; the Rust compiler cannot. The fix in a safe wrapper is to copy the string into an owned <code>String</code> at the call, which is the main thing <a href=\"/library/safe-rust-bindings-c-library\">the wrapper guide</a> does.</p>\n<h3>Callbacks</h3>\n<p><code>kv_foreach</code> takes a function pointer. The Rust side defines <code>extern \"C\" fn print_entry(...)</code>, an ordinary function with the C calling convention, and passes it as the argument. It cannot be a closure that captures variables, because a C function pointer has no place to store captured state. The <code>user_data</code> parameter exists for that purpose: pass a pointer to your state, and the callback casts it back. The example passes <code>null_mut()</code> because it needs no state. One more rule: a panic must not unwind out of an <code>extern \"C\"</code> function into C code. Since Rust 1.81, an unwinding panic that reaches an <code>extern \"C\"</code> boundary aborts the process, which is the defined behavior, and the safe fix is to wrap the callback body in <code>std::panic::catch_unwind</code>.</p>\n<h2>Linking errors</h2>\n<p>If the build script does not compile the C file, the link fails, and the message is the one to recognize:</p>\n<pre><code class=\"language-text\">error: linking with `cc` failed: exit status: 1\n  = note: Undefined symbols for architecture arm64:\n            \"_kv_open\", referenced from:\nld: symbol(s) not found for architecture arm64</code></pre>\n<p>The compiler was happy because the extern block promised that <code>kv_open</code> exists. The linker looked for it and found nothing. On Linux the same error reads <code>undefined reference to 'kv_open'</code>. The causes, in order of likelihood: the build script did not run or did not compile the right file, the library name in <code>cargo:rustc-link-lib</code> is wrong, the library is a C++ one whose symbols are mangled, or a dynamic library is installed but not on the search path.</p>\n<h2>Let bindgen write the declarations</h2>\n<p>Hand-written declarations drift. The header changes, the extern block does not, and nobody notices until a crash. <code>bindgen</code> reads the header and generates the block, so the two cannot disagree. Run it once to see what it makes:</p>\n<pre><code class=\"language-bash\">bindgen csrc/kv.h --rust-target 1.85 --rust-edition 2024 \\\n  --allowlist-function 'kv_.*' --allowlist-type 'kv_.*' --no-layout-tests</code></pre>\n<pre data-filename=\"kv_bindings.rs\"><code class=\"language-rust\">#[repr(C)]\n#[derive(Debug)]\npub struct kv {\n    _unused: [u8; 0],\n}\npub type kv_t = kv;\nunsafe extern \"C\" {\n    pub fn kv_open(name: *const ::std::os::raw::c_char) -&gt; *mut kv_t;\n}\nunsafe extern \"C\" {\n    pub fn kv_set(\n        store: *mut kv_t,\n        key: *const ::std::os::raw::c_char,\n        value: *const ::std::os::raw::c_char,\n    ) -&gt; ::std::os::raw::c_int;\n}\nunsafe extern \"C\" {\n    pub fn kv_get(\n        store: *const kv_t,\n        key: *const ::std::os::raw::c_char,\n    ) -&gt; *const ::std::os::raw::c_char;\n}\nunsafe extern \"C\" {\n    pub fn kv_len(store: *const kv_t) -&gt; usize;\n}\npub type kv_visit_fn = ::std::option::Option&lt;\n    unsafe extern \"C\" fn(\n        key: *const ::std::os::raw::c_char,\n        value: *const ::std::os::raw::c_char,\n        user_data: *mut ::std::os::raw::c_void,\n    ),\n&gt;;</code></pre>\n<p>(The listing is trimmed after the callback type; the real output also declares <code>kv_foreach</code> and <code>kv_close</code>.) Compare it with the hand-written version. It found the opaque struct and generated the same zero-length-array idiom. It mapped <code>size_t</code> to <code>usize</code>. And it made the callback type an <code>Option</code> of an <code>unsafe extern \"C\" fn</code>, because a C function pointer may be NULL. The hand-written version missed that, which means passing a null callback was not expressible; the generated one is more accurate.</p>\n<p>The <code>--allowlist-*</code> flags matter. Without them <code>bindgen</code> also emits every type from <code>stddef.h</code>, and from any system header the file includes, which bloats the output and breaks on platform differences. List what you intend to use.</p>\n<h3>Running it from build.rs</h3>\n<p>For a real project, generate at build time so the bindings track the header:</p>\n<pre data-filename=\"build.rs\"><code class=\"language-rust\">use std::{env, path::PathBuf};\n\nfn main() {\n    cc::Build::new().file(\"csrc/kv.c\").include(\"csrc\").warnings(true).compile(\"kv\");\n\n    let bindings = bindgen::Builder::default()\n        .header(\"csrc/kv.h\")\n        .allowlist_function(\"kv_.*\")\n        .allowlist_type(\"kv_.*\")\n        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))\n        .generate()\n        .expect(\"bindgen could not read csrc/kv.h\");\n\n    let out = PathBuf::from(env::var_os(\"OUT_DIR\").unwrap());\n    bindings.write_to_file(out.join(\"kv_bindings.rs\")).unwrap();\n}</code></pre>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">#![allow(non_camel_case_types, dead_code)]\n\nmod sys {\n    include!(concat!(env!(\"OUT_DIR\"), \"/kv_bindings.rs\"));\n}\n\nuse std::ffi::{CStr, CString};\n\nfn main() {\n    let name = CString::new(\"settings\").unwrap();\n    let store = unsafe { sys::kv_open(name.as_ptr()) };\n    let (k, v) = (CString::new(\"port\").unwrap(), CString::new(\"8080\").unwrap());\n    unsafe { sys::kv_set(store, k.as_ptr(), v.as_ptr()) };\n    let got = unsafe { CStr::from_ptr(sys::kv_get(store, k.as_ptr())) };\n    println!(\"port -&gt; {}\", got.to_str().unwrap());\n    unsafe { sys::kv_close(store) };\n}</code></pre>\n<pre><code class=\"language-text\">port -&gt; 8080</code></pre>\n<p><code>bindgen</code> as a build dependency needs <code>libclang</code> installed on the building machine, which is the cost of this approach: contributors and CI runners must have it. Many <code>-sys</code> crates avoid that by running <code>bindgen</code> once, checking the generated file into the repository, and regenerating only when the header changes. That is a trade of a build dependency for a file to maintain, and it is the usual choice for a crate that other people depend on.</p>\n<h2>Passing a closure through <code>user_data</code></h2>\n<p>A bare <code>extern \"C\" fn</code> cannot capture variables, so the printing callback above could only write to the terminal. Real code wants to collect results, and the C API provides the escape hatch it always provides: a <code>void *user_data</code> that comes back untouched. The standard technique is a trampoline, a generic C-callable function that casts <code>user_data</code> back to a pointer to the Rust closure and calls it. Because it is generic over the closure type <code>F</code>, the compiler stamps out one trampoline per closure, and each is an ordinary function pointer that C can store.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::ffi::{c_char, c_int, c_void, CStr, CString};\n\n#[repr(C)]\npub struct kv_t {\n    _private: [u8; 0],\n}\n\nunsafe extern \"C\" {\n    fn kv_open(name: *const c_char) -&gt; *mut kv_t;\n    fn kv_set(store: *mut kv_t, key: *const c_char, value: *const c_char) -&gt; c_int;\n    fn kv_foreach(\n        store: *const kv_t,\n        visit: Option&lt;unsafe extern \"C\" fn(*const c_char, *const c_char, *mut c_void)&gt;,\n        user: *mut c_void,\n    );\n    fn kv_close(store: *mut kv_t);\n}\n\n/// One C-callable function that forwards to whatever Rust closure `user` points at.\nunsafe extern \"C\" fn trampoline&lt;F: FnMut(&amp;str, &amp;str)&gt;(key: *const c_char, value: *const c_char, user: *mut c_void) {\n    // SAFETY: `user` is the `&amp;mut F` that `for_each` passed in below, and it outlives the C call.\n    let closure = unsafe { &amp;mut *(user as *mut F) };\n    let (k, v) = unsafe { (CStr::from_ptr(key), CStr::from_ptr(value)) };\n    // A panic must not cross into C: swallow it here and let the caller decide what to do.\n    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n        closure(&amp;k.to_string_lossy(), &amp;v.to_string_lossy());\n    }));\n}\n\nfn for_each&lt;F: FnMut(&amp;str, &amp;str)&gt;(store: *const kv_t, mut f: F) {\n    unsafe { kv_foreach(store, Some(trampoline::&lt;F&gt;), &amp;mut f as *mut F as *mut c_void) };\n}\n\nfn main() {\n    let name = CString::new(\"demo\").unwrap();\n    let store = unsafe { kv_open(name.as_ptr()) };\n    for (k, v) in [(\"a\", \"1\"), (\"b\", \"22\"), (\"c\", \"333\")] {\n        let (k, v) = (CString::new(k).unwrap(), CString::new(v).unwrap());\n        unsafe { kv_set(store, k.as_ptr(), v.as_ptr()) };\n    }\n\n    // The closure captures local state, which a bare `extern \"C\" fn` could not do.\n    let mut total_len = 0;\n    let mut keys = Vec::new();\n    for_each(store, |k, v| {\n        total_len += v.len();\n        keys.push(k.to_string());\n    });\n    println!(\"keys {keys:?}, total value length {total_len}\");\n\n    // What C does with bad arguments is part of the contract too.\n    let rc = unsafe { kv_set(store, std::ptr::null(), std::ptr::null()) };\n    println!(\"kv_set with NULL arguments returned {rc}\");\n\n    unsafe { kv_close(store) };\n}</code></pre>\n<pre><code class=\"language-text\">keys [\"a\", \"b\", \"c\"], total value length 6\nkv_set with NULL arguments returned -1</code></pre>\n<p>The closure pushed into <code>keys</code> and added to <code>total_len</code>, both locals of <code>main</code>, which proves the state travelled through C and back. Four details make the trampoline sound, and each is something you would otherwise get wrong. <code>&amp;mut f as *mut F as *mut c_void</code> turns the closure into a thin pointer; it is valid only for the duration of <code>kv_foreach</code>, which is exactly how long C may use it, and the comment on the <code>unsafe</code> block says so. The <code>Some(trampoline::&lt;F&gt;)</code> is the <code>Option</code> that <code>bindgen</code> generated for a nullable function pointer. The <code>catch_unwind</code> swallows any panic from the closure so it does not unwind through C frames, which is undefined behavior in older Rust and an abort in current Rust. And <code>AssertUnwindSafe</code> is the honest statement that you accept the closure's captured state may be half-updated after a panic.</p>\n<p>The last line of output is a reminder that the C library's behavior on bad input is part of its contract. <code>kv_set</code> with NULL arguments returned -1 rather than crashing, as the header said it would. A library that does not promise that, and many do not, means your wrapper must validate arguments before they reach C. Pass NULL to a function that dereferences it and the result is a segmentation fault at best.</p>\n<h2>Testing across the boundary</h2>\n<p>Tests of FFI code are ordinary tests, with one extra tool worth the setup. A Rust test that calls the C library catches wrong declarations, wrong ownership and wrong lifetimes only if the bug causes a visible symptom, and a use after free often does not. Run the test binary under a memory checker. On Linux, <code>valgrind --error-exitcode=1 target/debug/deps/ckv-*</code> reports reads of freed memory and leaks in the C code and in your use of it. On macOS and Linux, building with <code>RUSTFLAGS=\"-Zsanitizer=address\"</code> on nightly, and compiling the C with <code>-fsanitize=address</code> (set through <code>CFLAGS</code> for the <code>cc</code> crate), instruments both sides. Miri cannot run foreign code, so it does not help across the boundary; it remains useful for the pure-Rust parts of your wrapper. Write at least these cases: an empty store, a key that is absent, overwriting a key, a string with a non-ASCII character, and the cleanup path. They cover the ownership rules the header documented only in comments.</p>\n<h2>The conventions of a -sys crate</h2>\n<p>If you plan to publish the bindings, the ecosystem has a naming convention worth following. The crate with the raw, generated declarations is named <code>foo-sys</code>, contains no logic, and declares <code>links = \"foo\"</code> in its <code>Cargo.toml</code> so Cargo can ensure that only one version of the native library is linked into a build. A separate crate, <code>foo</code>, builds the safe API on top. The split lets many safe wrappers share one set of bindings, and it keeps the unsafe surface in a place with a small, auditable job. The raw bindings are never meant for application code; they are the foundation for <a href=\"/library/safe-rust-bindings-c-library\">a safe wrapper</a>.</p>\n<h2>What can still go wrong</h2>\n<p>Even with correct declarations, a handful of mistakes remain, each of which compiles.</p>\n<ul><li>Calling a C function from several threads when the library is not thread-safe. Check the library's documentation, and wrap the handle in a <code>Mutex</code> or leave it <code>!Send</code> if in doubt.</li><li>Forgetting to call the cleanup function, which leaks, or calling it twice, which crashes. Cleanup belongs in a <code>Drop</code> impl on a wrapper type, not in the code that uses the library.</li><li>Assuming a C <code>int</code> is 32 bits and a <code>long</code> is 64. They are not on every platform; use the <code>c_*</code> aliases.</li><li>Passing a Rust struct to C without <code>#[repr(C)]</code>, which gives Rust the freedom to reorder fields.</li><li>Ignoring return codes. Every function that can fail returns a status, and an <code>assert_eq!(rc, 0)</code> in a demo becomes a real error path in a program.</li></ul>\n<h2>When not to call C</h2>\n<p>If a Rust crate already does the job, use it: an equivalent written in Rust avoids the entire class of problems above and gets the compiler's checks across the boundary. If the C library is large and you only need a fraction, vendoring that fraction is easier than binding it all. And if the only reason is speed, measure first, because a pure Rust implementation is often within a few percent. When none of that applies, as with a mature library such as SQLite, zlib or libcurl, binding is the right call, and the sequence in this guide, a hand-written trial, then <code>bindgen</code>, then a safe wrapper, is the path. The other direction, exposing Rust to C, is covered in <a href=\"/library/call-rust-from-c-cbindgen\">calling Rust from C with cbindgen</a>.</p>\n<h2>A reading order for any C header</h2>\n<p>When you face an unfamiliar C library, read its header in this order and note the answer to each question before writing any Rust. Who allocates each pointer and who frees it? Which functions return a status, and which signal failure through a special return value like NULL or -1? Is there global initialization that must run once, and teardown that must run last? Which functions may be called from several threads at the same time? And which pointers returned by the library stay valid only until another call? Write those answers as comments on the extern declarations. They become the <code># Safety</code> sections of your wrapper, and they are the difference between bindings that merely compile and bindings someone can trust.</p>","date_published":"2026-11-03T09:44:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/rust-safe-wrapper-unsafe","url":"https://rusthacks.dev/library/rust-safe-wrapper-unsafe","title":"Writing a Safe Wrapper Around Unsafe Code in Rust Explained","summary":"The goal of unsafe code is to contain it. A safe wrapper has a small unsafe core, private fields that protect invariants and an API that cannot be misused from safe code.","content_html":"<p>Unsafe code in Rust is not meant to be sprinkled through a program. It is meant to live in a small module, behind an interface that cannot be misused, so that the rest of the program can stay in the safe subset and still use the result. That arrangement is called a safe abstraction, and building one is the core skill of unsafe Rust. The standard library is full of them: <code>Vec</code> manages raw memory, <code>Mutex</code> hands out references across threads, <code>Rc</code> counts owners by hand, and callers of all three write no <code>unsafe</code>.</p>\n<p>This guide builds one from scratch: a fixed-capacity ring buffer that stores its items inline in an array, with no heap allocation. It is small enough to read in a sitting, it needs real unsafe code, and it has three distinct ways to get wrong that Miri can show you. Everything compiled and ran on Rust 1.99 (edition 2024) and on the nightly toolchain's Miri; the output blocks are what they printed.</p>\n<h2>The problem the abstraction solves</h2>\n<p>A ring buffer is a queue with a fixed number of slots. Pushing writes at the tail, popping reads at the head, and both indices wrap around the end of the array. It is the standard structure for a producer and consumer that must never allocate, such as an interrupt handler feeding a main loop, or an audio callback.</p>\n<p>In safe Rust, the obvious design is <code>[Option&lt;T&gt;; N]</code>. It works and costs a discriminant per slot, and for a type like <code>String</code> or <code>Box</code>, where <code>Option</code> can reuse the null pointer, the cost is zero. For a type with no spare bit pattern, like <code>u32</code>, every slot grows by a word. More importantly, <code>[Option&lt;T&gt;; N]</code> requires initializing every slot to <code>None</code> up front and cannot move a value out of a slot without <code>take()</code>. The design this guide builds uses <code>MaybeUninit&lt;T&gt;</code>: a slot is raw, uninitialized storage, and the ring itself tracks which ones currently hold values. That tracking is the invariant the unsafe code depends on, and it is what the whole abstraction exists to protect.</p>\n<h2>State the invariant first</h2>\n<p>Before writing a line, write down what must always be true. This is the step that separates an abstraction from a collection of <code>unsafe</code> blocks.</p>\n<p>The ring has three fields: <code>slots</code>, <code>head</code> and <code>len</code>. The invariant is:</p>\n<ol><li><code>head</code> is always less than <code>N</code>, and <code>len</code> is always at most <code>N</code>.</li><li>The <code>len</code> slots starting at <code>head</code>, counting around the end of the array, are initialized.</li><li>Every other slot is uninitialized and is never read and never dropped.</li></ol>\n<p>Every method has to preserve it, and every unsafe block is justified by it. If a method breaks it even briefly, even in a state no one can observe, a panic at the wrong moment can expose the broken state. The code below is written so that no method can panic between changing the indices and reading the slot.</p>\n<h2>The implementation</h2>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::mem::MaybeUninit;\n\n/// A fixed-capacity FIFO queue that stores its items inline, with no heap allocation.\n///\n/// Invariant: exactly the slots in the logical range `head .. head + len` (mod N)\n/// are initialized. Every other slot is uninitialized and must never be read or dropped.\npub struct Ring&lt;T, const N: usize&gt; {\n    slots: [MaybeUninit&lt;T&gt;; N],\n    head: usize,\n    len: usize,\n}\n\nimpl&lt;T, const N: usize&gt; Ring&lt;T, N&gt; {\n    pub fn new() -&gt; Self {\n        Ring { slots: [const { MaybeUninit::uninit() }; N], head: 0, len: 0 }\n    }\n\n    pub fn len(&amp;self) -&gt; usize {\n        self.len\n    }\n\n    /// Returns the item back if the ring is full.\n    pub fn push(&amp;mut self, item: T) -&gt; Result&lt;(), T&gt; {\n        if self.len == N {\n            return Err(item);\n        }\n        let tail = (self.head + self.len) % N;\n        self.slots[tail].write(item); // writing a MaybeUninit slot is safe\n        self.len += 1;\n        Ok(())\n    }\n\n    pub fn pop(&amp;mut self) -&gt; Option&lt;T&gt; {\n        if self.len == 0 {\n            return None;\n        }\n        let index = self.head;\n        self.head = (self.head + 1) % N;\n        self.len -= 1;\n        // SAFETY: `index` was inside the initialized range before `len` shrank, and\n        // moving `head` forward means no later call can reach this slot again until\n        // `push` rewrites it. So the value is read out exactly once.\n        Some(unsafe { self.slots[index].assume_init_read() })\n    }\n\n    pub fn peek(&amp;self) -&gt; Option&lt;&amp;T&gt; {\n        if self.len == 0 {\n            return None;\n        }\n        // SAFETY: `head` is inside the initialized range because `len &gt; 0`.\n        Some(unsafe { self.slots[self.head].assume_init_ref() })\n    }\n}\n\nimpl&lt;T, const N: usize&gt; Drop for Ring&lt;T, N&gt; {\n    fn drop(&amp;mut self) {\n        // Drop only the initialized items. `MaybeUninit` never drops its contents on its own.\n        while self.pop().is_some() {}\n    }\n}\n\nstruct Noisy(&amp;'static str);\n\nimpl Drop for Noisy {\n    fn drop(&amp;mut self) {\n        println!(\"dropping {}\", self.0);\n    }\n}\n\nfn main() {\n    let mut ring: Ring&lt;String, 3&gt; = Ring::new();\n    for word in [\"a\", \"b\", \"c\"] {\n        ring.push(word.to_string()).unwrap();\n    }\n    println!(\"full push rejected: {:?}\", ring.push(\"d\".to_string()));\n    println!(\"peek: {:?}\", ring.peek());\n    println!(\"pop:  {:?}\", ring.pop());\n    ring.push(\"e\".to_string()).unwrap(); // wraps around the end of the array\n    let drained: Vec&lt;_&gt; = std::iter::from_fn(|| ring.pop()).collect();\n    println!(\"drained in order: {drained:?}\");\n\n    let mut noisy: Ring&lt;Noisy, 4&gt; = Ring::new();\n    noisy.push(Noisy(\"first\")).ok();\n    noisy.push(Noisy(\"second\")).ok();\n    noisy.push(Noisy(\"third\")).ok();\n    let _ = noisy.pop();\n    println!(\"leaving main with {} items still queued\", noisy.len());\n}</code></pre>\n<p>Read the code with the invariant open beside it.</p>\n<p><code>new</code> creates <code>N</code> uninitialized slots with <code>[const { MaybeUninit::uninit() }; N]</code>. The <code>const</code> block is how you repeat a value that is not <code>Copy</code> in an array expression. Creating uninitialized storage is safe, because <code>MaybeUninit</code> makes no promise about its contents.</p>\n<p><code>push</code> checks capacity, computes the tail as <code>(head + len) % N</code>, and writes with <code>MaybeUninit::write</code>. Writing is safe for the same reason: it initializes the slot, it cannot expose anything. If the ring is full, <code>push</code> returns the item back inside <code>Err</code>, which is the idiomatic way to refuse without losing the caller's value. Only after the write does it increase <code>len</code>, which keeps the order of operations safe: the invariant never claims a slot is initialized before it is.</p>\n<p><code>pop</code> is the first place unsafe code appears, and it follows a strict order. It advances <code>head</code> and decrements <code>len</code> first, which removes the slot from the initialized range, and only then reads it with <code>assume_init_read</code>. The comment states the argument: the slot was inside the range, and shrinking the range means no later call can reach it before <code>push</code> rewrites it, so the value is read out exactly once. <code>assume_init_read</code> performs a bitwise copy and leaves the slot logically uninitialized again, which is exactly the move semantics wanted.</p>\n<p><code>peek</code> returns a reference to the front item using <code>assume_init_ref</code>. Its comment cites the invariant: with <code>len &gt; 0</code>, the slot at <code>head</code> is initialized. The reference borrows <code>&amp;self</code>, so the borrow checker prevents using it across a <code>push</code> or <code>pop</code>, which could invalidate it. That is the type system enforcing part of the invariant for free, and the next section shows the compiler doing it.</p>\n<p><code>Drop</code> is the part people forget. A <code>MaybeUninit</code> field never drops its contents, because it does not know whether they are initialized. The ring knows, so its <code>Drop</code> impl pops every remaining item, which runs each item's destructor exactly once, and leaves the uninitialized slots alone. The loop reuses <code>pop</code> instead of repeating the unsafe read, which keeps the number of <code>unsafe</code> blocks at two.</p>\n<h2>What running it shows</h2>\n<pre><code class=\"language-text\">full push rejected: Err(\"d\")\npeek: Some(\"a\")\npop:  Some(\"a\")\ndrained in order: [\"b\", \"c\", \"e\"]\ndropping first\nleaving main with 2 items still queued\ndropping second\ndropping third</code></pre>\n<p>Walk through it. The ring has three slots, so the fourth push is refused and the string comes back in <code>Err(\"d\")</code>. Peeking and popping both return <code>\"a\"</code>, the oldest item, so the order is first in, first out. After the pop, pushing <code>\"e\"</code> wraps around to the slot <code>\"a\"</code> left behind, and draining returns <code>[\"b\", \"c\", \"e\"]</code> in the right order, which exercises the wraparound arithmetic. The second half uses a type that prints when it is dropped. <code>dropping first</code> appears immediately when <code>let _ = noisy.pop()</code> discards the value. At the end of <code>main</code> two items are still queued, and the ring's <code>Drop</code> impl prints <code>dropping second</code> and <code>dropping third</code>. Every value was dropped exactly once, none twice and none never.</p>\n<p>This ran under Miri with no error. That is the standard to hold the abstraction to: Miri passing on tests that cover wraparound, a full ring, an empty ring and drop.</p>\n<h2>The borrow checker protects part of the invariant</h2>\n<p>The first draft of <code>main</code> printed the peeked value and the popped value in one statement:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">println!(\"peek: {:?}, pop: {:?}\", ring.peek(), ring.pop());</code></pre>\n<pre><code class=\"language-text\">error[E0502]: cannot borrow `ring` as mutable because it is also borrowed as immutable\n  --&gt; src/bin/ring.rs:76:52\n   |\n76 |     println!(\"peek: {:?}, pop: {:?}\", ring.peek(), ring.pop());\n   |     -----------------------------------------------^^^^^^^^^^-\n   |     |                                 |            |\n   |     |                                 |            mutable borrow occurs here\n   |     |                                 immutable borrow occurs here\n   |     immutable borrow later used here</code></pre>\n<p>This was an accident, and a good one. <code>peek</code> returns a <code>&amp;T</code> pointing into the slot, and that reference is still alive when <code>pop</code> runs. If the program were allowed to compile, <code>pop</code> would move the value out of the slot while the reference still points at it, and the reference would see memory that no longer holds a valid <code>T</code>. The compiler rejected it because <code>peek</code> takes <code>&amp;self</code> and returns a reference tied to that borrow, while <code>pop</code> takes <code>&amp;mut self</code>. You did not write any rule saying \"do not pop while peeking\"; the signatures encode it. That is what a good safe interface does: it makes a class of misuse a compile error.</p>\n<h2>Three ways the abstraction breaks</h2>\n<p>Now the failures, because an abstraction is only as good as the bugs it prevents. Each one below was made by changing the working code in one place and running Miri.</p>\n<h3>Forgetting Drop</h3>\n<p>Delete the <code>Drop</code> impl. The ring now never drops its items. For <code>Copy</code> types that is invisible. For <code>String</code>, every item still queued at the end owns a heap buffer that is never freed:</p>\n<pre><code class=\"language-text\">Some(\"alpha\")\nerror: memory leaked: alloc410 (Rust heap, size: 4, align: 1), allocated here:\n   --&gt; .../library/alloc/src/raw_vec/mod.rs:485:41\n    |\n485 |             AllocInit::Uninitialized =&gt; alloc.allocate(layout),</code></pre>\n<p>Miri reports the leak at exit, with the allocation's size. Nothing crashed and the output looked right. Leaks of this kind pass every ordinary test and surface as slowly growing memory in production, which is why running Miri on unsafe containers is worth the minutes it takes.</p>\n<h3>Reading a slot twice</h3>\n<p>Remove the two lines in <code>pop</code> that advance <code>head</code> and shrink <code>len</code>. Now popping reads the same slot each time while still claiming it holds a value:</p>\n<pre><code class=\"language-text\">Some(\"alpha\")\nerror: Undefined Behavior: constructing invalid value of type &amp;[u8]: encountered a dangling reference (use-after-free)\n    --&gt; .../library/alloc/src/vec/mod.rs:1784:13\n     |\n1784 |             &amp;*core::intrinsics::aggregate_raw_ptr::&lt;*const [T], _, _&gt;(self.as_ptr(), self.len)\n     |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here</code></pre>\n<p>The first <code>pop</code> printed <code>Some(\"alpha\")</code> and dropped the returned string. The second <code>pop</code> read the same bytes again, producing a second <code>String</code> whose buffer had already been freed. Miri catches it at the moment the freed buffer is touched. In a normal build this is a double free: the allocator's bookkeeping is corrupted, and the crash happens later, somewhere unrelated. The bug is a single missing statement in a safe-looking method, which is the main hazard of unsafe code: the unsafe block was fine, and the safe code around it broke the invariant it depends on.</p>\n<h3>Dropping the wrong slots</h3>\n<p>The third failure is the subtle one, and it is a bug you cannot demonstrate by deleting a line. If the <code>Drop</code> impl iterated over every slot and called <code>assume_init_drop</code> on each, it would drop uninitialized memory for the empty ones, which is undefined behavior for any <code>T</code> with a destructor. The <code>while self.pop().is_some() {}</code> loop avoids it by construction: <code>pop</code> only touches slots the invariant says are initialized. When you write an unsafe collection, prefer expressing cleanup in terms of your own safe-to-reason-about operations over repeating index arithmetic.</p>\n<h2>Tests that make each failure fail</h2>\n<p>The three failures above were found by hand-editing the code. A proper test suite finds them for you. Four tests cover the cases that matter for this container, and the third one is the drop-count test that would have caught both the missing <code>Drop</code> and the double read.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::cell::Cell;\n    use std::rc::Rc;\n\n    /// Counts how many times it has been dropped, through a shared cell.\n    struct Counted(Rc&lt;Cell&lt;usize&gt;&gt;);\n\n    impl Drop for Counted {\n        fn drop(&amp;mut self) {\n            self.0.set(self.0.get() + 1);\n        }\n    }\n\n    #[test]\n    fn fifo_order_survives_wraparound() {\n        let mut r: Ring&lt;u32, 3&gt; = Ring::new();\n        for round in 0..10 {\n            r.push(round).unwrap();\n            r.push(round + 100).unwrap();\n            assert_eq!(r.pop(), Some(round));\n            assert_eq!(r.pop(), Some(round + 100));\n        }\n        assert_eq!(r.pop(), None);\n    }\n\n    #[test]\n    fn full_ring_returns_the_item() {\n        let mut r: Ring&lt;String, 1&gt; = Ring::new();\n        r.push(\"kept\".into()).unwrap();\n        assert_eq!(r.push(\"rejected\".into()), Err(\"rejected\".to_string()));\n    }\n\n    #[test]\n    fn every_item_is_dropped_exactly_once() {\n        let drops = Rc::new(Cell::new(0));\n        {\n            let mut r: Ring&lt;Counted, 4&gt; = Ring::new();\n            for _ in 0..4 {\n                r.push(Counted(drops.clone())).ok().unwrap();\n            }\n            drop(r.pop()); // one dropped by the caller\n            assert_eq!(drops.get(), 1);\n            r.push(Counted(drops.clone())).ok().unwrap(); // wraps\n        } // the ring drops the remaining four\n        assert_eq!(drops.get(), 5);\n    }\n\n    #[test]\n    fn zero_sized_items_work() {\n        let mut r: Ring&lt;(), 2&gt; = Ring::new();\n        r.push(()).unwrap();\n        r.push(()).unwrap();\n        assert!(r.push(()).is_err());\n        assert_eq!(r.pop(), Some(()));\n    }\n}</code></pre>\n<p>Run them under Miri, not just <code>cargo test</code>. A normal test run would pass a ring that leaks or double-drops in many cases, because the damage is silent; Miri turns each into an error.</p>\n<pre><code class=\"language-bash\">cargo +nightly miri test</code></pre>\n<pre><code class=\"language-text\">running 4 tests\n....\ntest result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s</code></pre>\n<p>The tests earn their keep through what they check. <code>fifo_order_survives_wraparound</code> pushes and pops ten rounds through a three-slot ring, so the head index wraps around the array several times; an off-by-one in the modulo shows up as a wrong value. <code>full_ring_returns_the_item</code> pins the contract that a refused push hands the value back rather than dropping it. <code>every_item_is_dropped_exactly_once</code> counts destructor calls through a shared <code>Rc&lt;Cell&lt;usize&gt;&gt;</code>: one drop from the caller's <code>drop(r.pop())</code>, then four more when the ring goes out of scope, five in total for the five items pushed. If <code>Drop</code> were missing, the final assertion would read 1; if <code>pop</code> read a slot twice, Miri would stop the test first. And <code>zero_sized_items_work</code> covers <code>Ring&lt;(), 2&gt;</code>, a case people forget, since a zero-sized <code>T</code> makes pointer arithmetic degenerate and <code>MaybeUninit&lt;()&gt;</code> take no space at all.</p>\n<h2>Panic safety: what if a destructor panics?</h2>\n<p>One more property separates a container that is correct from one that is sound under every input. User code runs inside your methods whenever you call <code>T::drop</code>, and that code can panic. In <code>Ring::drop</code>, the loop calls <code>pop</code>, which moves the value out and then lets the caller's <code>Option</code> drop it. If item two's destructor panics, the unwinding begins while items three and four are still queued. What happens to them?</p>\n<p>With this implementation, the <code>while self.pop().is_some() {}</code> loop is abandoned mid-way by the panic, and the remaining items are leaked: their destructors do not run. A leak is safe in Rust, so the abstraction is still sound, merely wasteful in a rare case. The alternative, a guard object whose own <code>Drop</code> continues draining even while unwinding, makes the container leak-free at the price of more code and the possibility of a second panic during unwinding, which aborts the process. The standard library's <code>Vec::drain</code> and <code>VecDeque::drop</code> use that guard technique because they are part of the foundation. For a container of your own, the pragmatic rule is to guarantee soundness always and leak-freedom where it is cheap. What you must never do is leave the invariant false when a panic unwinds out of a method: if <code>push</code> incremented <code>len</code> before it wrote the item, and the write could panic (it cannot here, but a <code>clone</code> before the write could), a later <code>pop</code> would read an uninitialized slot. That is why the order inside each method matters as much as the code in it.</p>\n<h2>Deciding whether the type is Send and Sync</h2>\n<p>A struct made of safe types gets <code>Send</code> and <code>Sync</code> automatically. <code>Ring&lt;T, N&gt;</code> holds <code>[MaybeUninit&lt;T&gt;; N]</code> and two <code>usize</code>, and <code>MaybeUninit&lt;T&gt;</code> is <code>Send</code> when <code>T</code> is and <code>Sync</code> when <code>T</code> is, so the compiler infers that the ring is <code>Send</code> if <code>T: Send</code> and <code>Sync</code> if <code>T: Sync</code>. That inference is correct here, since nothing in the ring is tied to a thread.</p>\n<p>You override the automatic rules, with <code>unsafe impl Send</code>, only when the struct contains a raw pointer or other type the compiler refuses to assume is thread-safe, and then the promise is yours. The question to answer is \"if two threads each had access, could they cause a data race or break an invariant?\" For a <code>Ring</code>, a shared <code>&amp;Ring</code> offers only <code>len</code> and <code>peek</code>, which read; <code>push</code> and <code>pop</code> need <code>&amp;mut</code>, which is exclusive. So <code>Sync</code> is sound without any synchronization, and the type system keeps writers exclusive. The moment you want a ring that two threads push into and pop from at the same time, you need atomics for <code>head</code> and <code>len</code>, and the invariant argument becomes a lock-free design problem, a different and much harder one.</p>\n<h2>A checklist for any safe abstraction over unsafe code</h2>\n<ul><li>Write the invariant as a numbered list in the doc comment of the type, before writing methods.</li><li>Keep the fields private. A public field lets any caller break the invariant without writing <code>unsafe</code>.</li><li>Order operations so the invariant never claims more than is true: initialize before increasing the count, decrease the count before reading out.</li><li>Put a <code>// SAFETY:</code> comment on every <code>unsafe</code> block that cites the invariant and the specific condition.</li><li>Implement <code>Drop</code> explicitly, and test it with a type that prints or counts drops, so a leak and a double drop both fail a test.</li><li>Consider panic safety. If a user-supplied <code>Clone</code> or <code>Drop</code> can panic halfway through your method, ensure the invariant still holds when the panic unwinds.</li><li>Run Miri on the tests, including the edge cases: empty, full, wraparound, zero-sized types, and a type with a destructor.</li><li>Check the auto traits. Confirm <code>Send</code> and <code>Sync</code> do what you mean, or assert them with a compile-time test.</li></ul>\n<h2>When to use a crate instead</h2>\n<p>Before shipping a hand-written container, ask whether one already exists with a larger test suite. For this particular structure, <code>heapless::spsc::Queue</code> is the established no-alloc queue for embedded code, <code>arrayvec</code> gives a stack-allocated <code>Vec</code>, and <code>std::collections::VecDeque</code> is a growable ring buffer when allocation is acceptable. Write your own when none fits, or to learn. If you do, the discipline above is what makes it safe to depend on, and the first thing a reviewer should read is the comment that states the invariant. For the language rules the whole exercise rests on, read <a href=\"/library/unsafe-rust-explained\">unsafe Rust explained</a>, and for wrapping C instead of a data structure, <a href=\"/library/safe-rust-bindings-c-library\">safe Rust bindings for a C library</a>.</p>\n<p>Keep the finished type small and finish it with documentation a stranger can audit: the invariant list on the struct, the safety comment on each block, and the test names that read as claims. If another person can verify the argument without running anything, the abstraction has done its job.</p>","date_published":"2026-11-03T09:42:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/rust-custom-global-allocator","url":"https://rusthacks.dev/library/rust-custom-global-allocator","title":"Writing a Custom Global Allocator in Rust to Track Memory","summary":"A global allocator is a type implementing GlobalAlloc, registered with one attribute. Wrapping the system allocator with atomic counters gives you live memory numbers.","content_html":"<p>Every <code>Vec::push</code>, <code>String::from</code> and <code>Box::new</code> in a Rust program ends in a call to the same place: the global allocator. Normally you never see it, because the standard library supplies one that forwards to the operating system's <code>malloc</code>. But Rust lets you replace it with a single attribute, and that one hook turns out to be one of the most useful debugging and performance tools in the language. With a few dozen lines you can count every allocation your program makes, assert that a hot path makes none, cap memory in a test, or swap in a completely different allocation strategy.</p>\n<p>This guide builds three allocators, each small enough to read in one sitting: one that tracks allocations, one that allocates from a fixed arena and never frees, and a per-thread counter you can use in tests. Everything ran on Rust 1.99 with edition 2024 and only the standard library, in release mode on an Apple silicon Mac. The failures, including a crash with no message, are real and are explained, because writing an allocator is the one place in Rust where a perfectly ordinary line of code, a <code>println!</code>, can bring the whole process down.</p>\n<h2>What the hook is</h2>\n<p>The <code>GlobalAlloc</code> trait has two required methods and two optional ones:</p>\n<pre data-filename=\"src/lib.rs\"><code class=\"language-rust\">pub unsafe trait GlobalAlloc {\n    unsafe fn alloc(&amp;self, layout: Layout) -&gt; *mut u8;\n    unsafe fn dealloc(&amp;self, ptr: *mut u8, layout: Layout);\n\n    // Provided, with default implementations built on the two above:\n    unsafe fn alloc_zeroed(&amp;self, layout: Layout) -&gt; *mut u8 { /* ... */ }\n    unsafe fn realloc(&amp;self, ptr: *mut u8, layout: Layout, new_size: usize) -&gt; *mut u8 { /* ... */ }\n}</code></pre>\n<p>A <code>Layout</code> carries a size and an alignment. Returning a null pointer from <code>alloc</code> means \"out of memory\". The trait is <code>unsafe</code> because the rest of the program relies on you honoring its contract: the pointer you return must point to at least <code>layout.size()</code> bytes, aligned to <code>layout.align()</code>, not overlapping any live allocation, and valid until <code>dealloc</code> is called with the same layout. Violate it and the symptoms appear anywhere, much later, in code that has nothing to do with your allocator.</p>\n<p>You install one with the <code>#[global_allocator]</code> attribute on a <code>static</code>. There can be only one per program, and it applies to everything, including the standard library and every dependency.</p>\n<h2>An allocator that counts</h2>\n<p>The simplest useful allocator wraps the system one and keeps counters. It forwards every call to <code>System</code> and updates four atomics on the way. The <code>measure</code> function runs a closure and prints what it allocated.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::alloc::{GlobalAlloc, Layout, System};\nuse std::collections::HashMap;\nuse std::sync::atomic::{AtomicUsize, Ordering::Relaxed};\n\n/// Wraps the system allocator and keeps four counters. It never allocates itself.\nstruct Tracking {\n    live: AtomicUsize,\n    peak: AtomicUsize,\n    allocs: AtomicUsize,\n    frees: AtomicUsize,\n    reallocs: AtomicUsize,\n}\n\nimpl Tracking {\n    const fn new() -&gt; Self {\n        Self { live: AtomicUsize::new(0), peak: AtomicUsize::new(0), allocs: AtomicUsize::new(0), frees: AtomicUsize::new(0), reallocs: AtomicUsize::new(0) }\n    }\n\n    fn snapshot(&amp;self) -&gt; Snapshot {\n        Snapshot { live: self.live.load(Relaxed), peak: self.peak.load(Relaxed), allocs: self.allocs.load(Relaxed), frees: self.frees.load(Relaxed), reallocs: self.reallocs.load(Relaxed) }\n    }\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct Snapshot {\n    live: usize,\n    peak: usize,\n    allocs: usize,\n    frees: usize,\n    reallocs: usize,\n}\n\nunsafe impl GlobalAlloc for Tracking {\n    unsafe fn alloc(&amp;self, layout: Layout) -&gt; *mut u8 {\n        let ptr = unsafe { System.alloc(layout) };\n        if !ptr.is_null() {\n            self.allocs.fetch_add(1, Relaxed);\n            let now = self.live.fetch_add(layout.size(), Relaxed) + layout.size();\n            self.peak.fetch_max(now, Relaxed);\n        }\n        ptr\n    }\n\n    unsafe fn dealloc(&amp;self, ptr: *mut u8, layout: Layout) {\n        unsafe { System.dealloc(ptr, layout) };\n        self.frees.fetch_add(1, Relaxed);\n        self.live.fetch_sub(layout.size(), Relaxed);\n    }\n\n    // Forward realloc so the system allocator can grow in place; the default\n    // implementation would allocate, copy and free, which counts as three events.\n    unsafe fn realloc(&amp;self, ptr: *mut u8, layout: Layout, new_size: usize) -&gt; *mut u8 {\n        let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };\n        if !new_ptr.is_null() {\n            self.reallocs.fetch_add(1, Relaxed);\n            if new_size &gt;= layout.size() {\n                let now = self.live.fetch_add(new_size - layout.size(), Relaxed) + (new_size - layout.size());\n                self.peak.fetch_max(now, Relaxed);\n            } else {\n                self.live.fetch_sub(layout.size() - new_size, Relaxed);\n            }\n        }\n        new_ptr\n    }\n}\n\n#[global_allocator]\nstatic ALLOC: Tracking = Tracking::new();\n\n/// Run `f` and report what it allocated.\nfn measure&lt;R&gt;(label: &amp;str, f: impl FnOnce() -&gt; R) -&gt; R {\n    let before = ALLOC.snapshot();\n    ALLOC.peak.store(before.live, Relaxed); // peak is measured from this call's start\n    let out = f();\n    let after = ALLOC.snapshot();\n    println!(\n        \"{label:&lt;26} allocs {:&gt;5}  reallocs {:&gt;3}  frees {:&gt;5}  net {:&gt;+7} B  peak +{:&gt;6} B\",\n        after.allocs - before.allocs,\n        after.reallocs - before.reallocs,\n        after.frees - before.frees,\n        after.live as isize - before.live as isize,\n        after.peak.saturating_sub(before.live),\n    );\n    out\n}\n\nfn main() {\n    measure(\"Vec::new + 1000 pushes\", || {\n        let mut v = Vec::new();\n        for i in 0..1000u32 {\n            v.push(i);\n        }\n        v.len()\n    });\n\n    measure(\"Vec::with_capacity(1000)\", || {\n        let mut v = Vec::with_capacity(1000);\n        for i in 0..1000u32 {\n            v.push(i);\n        }\n        v.len()\n    });\n\n    measure(\"1000 format! strings\", || (0..1000).map(|i| format!(\"item-{i}\")).count());\n\n    let kept = measure(\"HashMap of 100 Strings\", || {\n        let mut m = HashMap::new();\n        for i in 0..100 {\n            m.insert(i, \"x\".repeat(32));\n        }\n        m\n    });\n    println!(\"kept {} entries alive, live bytes now {}\", kept.len(), ALLOC.snapshot().live);\n\n    // A leak shows up as live bytes that never come back.\n    measure(\"leaking 10 boxes\", || {\n        for _ in 0..10 {\n            std::mem::forget(Box::new([0u8; 1024]));\n        }\n    });\n}</code></pre>\n<p>Running the program produced this:</p>\n<pre><code class=\"language-text\">Vec::new + 1000 pushes     allocs     1  reallocs   8  frees     1  net      +0 B  peak +  4096 B\nVec::with_capacity(1000)   allocs     1  reallocs   0  frees     1  net      +0 B  peak +  4000 B\n1000 format! strings       allocs  1000  reallocs   0  frees  1000  net      +0 B  peak +    10 B\nHashMap of 100 Strings     allocs   106  reallocs   0  frees     5  net   +7432 B  peak +  8176 B\nkept 100 entries alive, live bytes now 9132\nleaking 10 boxes           allocs    10  reallocs   0  frees     0  net  +10240 B  peak + 10240 B</code></pre>\n<p>This table teaches more about Rust's memory behavior than a chapter of explanation, so read it row by row.</p>\n<p>The first two rows run the same loop. Pushing 1,000 <code>u32</code> values into a <code>Vec::new()</code> made one allocation and then eight reallocations, as the capacity doubled from 4 to 8, 16, 32, 64, 128, 256, 512 and 1,024. <code>Vec::with_capacity(1000)</code> made one allocation and zero reallocations, and peaked at 4,000 bytes against 4,096. That is the whole argument for <code>with_capacity</code> when you know the size: the same work with a ninth of the allocator traffic.</p>\n<p>The third row creates a thousand short-lived strings. There were 1,000 allocations and 1,000 frees, and the peak was a mere 10 bytes, because each string was dropped before the next was built. Allocation count and memory use are different costs. A program that makes a million small allocations is slow even if it never holds more than a kilobyte.</p>\n<p>The fourth row builds a <code>HashMap</code> and keeps it. 106 allocations, 5 frees, and a net 7,432 bytes still live: 100 strings of 32 bytes plus the table itself, which also reallocated as it grew (the 5 frees are the old tables). The line below it confirms the map is still alive and counts total live bytes at 9,132.</p>\n<p>The last row is a deliberate leak. <code>std::mem::forget</code> skips the destructor, so ten 1 KiB boxes were allocated and never freed, and <code>net</code> reads +10,240 bytes. A leak is any row where net is positive and nothing holds the data. The tracking allocator will not tell you who owns what, but it will tell you, for any block of code, whether memory came back. Wrap a request handler in <code>measure</code> in a test and a leak announces itself.</p>\n<h3>Why <code>realloc</code> is forwarded</h3>\n<p>The trait provides a default <code>realloc</code> that allocates a new block, copies, and frees the old one. That is correct and wasteful: the system allocator can often grow a block in place without copying. If your wrapper does not forward <code>realloc</code> to <code>System.realloc</code>, you change the program's performance by observing it. The listing forwards it and keeps its own <code>reallocs</code> counter, because without that the <code>Vec</code> row would show a single allocation and hide eight events.</p>\n<h3>Measuring a peak that means something</h3>\n<p>The <code>peak</code> counter is global, so a naive reading includes the peak of everything that ran before. The fix is the line <code>ALLOC.peak.store(before.live, Relaxed)</code> at the start of <code>measure</code>, which resets the high-water mark to the current level. Without it, the third row would have reported the 4,096-byte peak from the first row instead of 10 bytes. This is a common bug in home-made profilers: a counter that never resets reports history, not the thing you measured.</p>\n<p>The counters use <code>Ordering::Relaxed</code>, which is correct here because they are independent statistics and nothing else synchronizes through them. Under threads, the numbers are exact in total but the snapshot you read is not a consistent cut across all four; two counters read a few nanoseconds apart may disagree by an in-flight allocation. For profiling that is fine. Do not use these values to make correctness decisions.</p>\n<h2>The mistake that crashes with no message</h2>\n<p>Here is the allocator everyone writes first, to see allocations happen:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">unsafe impl GlobalAlloc for Noisy {\n    unsafe fn alloc(&amp;self, layout: Layout) -&gt; *mut u8 {\n        eprintln!(\"alloc {} bytes\", layout.size());\n        unsafe { System.alloc(layout) }\n    }\n    unsafe fn dealloc(&amp;self, ptr: *mut u8, layout: Layout) {\n        unsafe { System.dealloc(ptr, layout) }\n    }\n}</code></pre>\n<p>It compiles without a warning. Running a program that uses it printed nothing at all, and the process exited with status 139, a segmentation fault. The cause is recursion. Formatting a message and writing it to a stream needs memory, so <code>eprintln!</code> allocates, which calls <code>alloc</code>, which calls <code>eprintln!</code>, which allocates, until the stack overflows. The stack overflow happens inside the allocator, before any output reaches the screen, so there is no message to read.</p>\n<p>The rule is that code inside <code>alloc</code> and <code>dealloc</code> must never allocate and must never take a lock the allocating code might already hold. Writing a number to a file descriptor with the <code>write</code> system call directly is fine. Anything that formats, logs, builds a <code>String</code> or touches a <code>thread_local</code> with lazy initialization is not. Keep the allocator to atomics and plain arithmetic, record events, and do the printing later from outside, as <code>measure</code> does.</p>\n<h2>An allocator that never frees: the bump arena</h2>\n<p>At the other end of the design space is the allocator that does almost nothing. A bump allocator owns a block of memory and a cursor. Allocating rounds the cursor up to the requested alignment, moves it forward by the size and returns the old position. Freeing does nothing at all.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::alloc::{GlobalAlloc, Layout};\nuse std::cell::UnsafeCell;\nuse std::sync::atomic::{AtomicUsize, Ordering};\n\nconst ARENA_SIZE: usize = 1 &lt;&lt; 20; // 1 MiB\n\n/// A bump allocator: allocation moves a pointer forward, freeing does nothing.\n/// Fast and tiny, and the right tool for a short-lived process or a request arena.\nstruct Bump {\n    arena: UnsafeCell&lt;[u8; ARENA_SIZE]&gt;,\n    next: AtomicUsize,\n}\n\n// SAFETY: the only mutation of `arena` is through pointers handed out once each by `alloc`;\n// `next` is advanced atomically, so two threads never receive overlapping ranges.\nunsafe impl Sync for Bump {}\n\nunsafe impl GlobalAlloc for Bump {\n    unsafe fn alloc(&amp;self, layout: Layout) -&gt; *mut u8 {\n        let base = self.arena.get() as usize;\n        let mut current = self.next.load(Ordering::Relaxed);\n        loop {\n            // Round the next free address up to the alignment the caller asked for.\n            let start = (base + current).next_multiple_of(layout.align()) - base;\n            let end = match start.checked_add(layout.size()) {\n                Some(end) if end &lt;= ARENA_SIZE =&gt; end,\n                _ =&gt; return std::ptr::null_mut(), // out of arena\n            };\n            match self.next.compare_exchange_weak(current, end, Ordering::Relaxed, Ordering::Relaxed) {\n                Ok(_) =&gt; return (base + start) as *mut u8,\n                Err(actual) =&gt; current = actual,\n            }\n        }\n    }\n\n    unsafe fn dealloc(&amp;self, _ptr: *mut u8, _layout: Layout) {}\n}\n\n#[global_allocator]\nstatic ALLOC: Bump = Bump { arena: UnsafeCell::new([0; ARENA_SIZE]), next: AtomicUsize::new(0) };\n\nfn used() -&gt; usize {\n    ALLOC.next.load(Ordering::Relaxed)\n}\n\nfn main() {\n    let start = used();\n    let mut names: Vec&lt;String&gt; = Vec::new();\n    for i in 0..1000 {\n        names.push(format!(\"user-{i:04}\"));\n    }\n    println!(\"1000 strings: arena used {} bytes\", used() - start);\n\n    let before = used();\n    drop(names);\n    println!(\"after drop:   arena used {} bytes (nothing came back)\", used() - before);\n\n    let before = used();\n    for _ in 0..3 {\n        let scratch: Vec&lt;u8&gt; = vec![0; 100_000];\n        std::hint::black_box(&amp;scratch);\n    }\n    println!(\"3 x 100 kB scratch buffers: arena used {} bytes\", used() - before);\n\n    println!(\"arena size {ARENA_SIZE}, used {} so far\", used());\n    let big: Vec&lt;u8&gt; = vec![0; 800_000];\n    println!(\"an 800 kB allocation succeeded: {}\", big.len());\n}</code></pre>\n<pre><code class=\"language-text\">1000 strings: arena used 59062 bytes\nafter drop:   arena used 0 bytes (nothing came back)\n3 x 100 kB scratch buffers: arena used 300000 bytes\narena size 1048576, used 360768 so far\nmemory allocation of 800000 bytes failed\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace</code></pre>\n<p>The program exited with status 134, an abort. Read the output as the allocator's contract. A thousand strings used 59,062 bytes. Dropping them gave back nothing, as the second line says. Three 100,000-byte scratch buffers used 300,000 bytes, because each one was allocated fresh and none was reclaimed. After that the cursor stood at 360,768 of the 1,048,576 bytes, and an 800,000-byte request did not fit, so <code>alloc</code> returned null and the runtime aborted with \"memory allocation of 800000 bytes failed\".</p>\n<p>That behavior is a feature when it is intended. A bump allocator is extremely fast, a few instructions per allocation with no bookkeeping, has zero fragmentation, and is the right tool when everything allocated lives until a known point and then dies together: a compiler pass, a command that runs for a second and exits, one request handled in an arena that is reset afterward. It is the wrong tool for a long-lived server, where a leak-by-design fills memory. For request-scoped arenas inside a normal program, use a local arena type such as <code>bumpalo</code> rather than replacing the global allocator.</p>\n<p>Three details in the code are worth studying. The alignment line, <code>(base + current).next_multiple_of(layout.align()) - base</code>, rounds the actual address, not the offset, because the static array itself is only aligned as strictly as its type requires. The <code>checked_add</code> and the comparison guard against both overflow and overrun, returning null instead of handing out memory past the end. And the <code>compare_exchange_weak</code> loop makes the cursor advance atomic, so two threads never receive overlapping ranges. The <code>unsafe impl Sync</code> carries a comment stating why it is sound, which is the convention to follow for every <code>unsafe</code> block you write.</p>\n<h2>A counter that is safe to use in tests</h2>\n<p>The global counters are awkward in tests, because Rust runs tests on several threads at once, and one test's allocations leak into another's counts. The cure is to count per thread. A <code>thread_local!</code> with a <code>const</code> initializer does not allocate on first use, so it is safe inside the allocator, and <code>try_with</code> avoids a panic during thread teardown.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::alloc::{GlobalAlloc, Layout, System};\nuse std::cell::Cell;\n\nthread_local! {\n    // `const` initialization: touching this never allocates, which matters inside an allocator.\n    static ALLOCS_ON_THIS_THREAD: Cell&lt;u64&gt; = const { Cell::new(0) };\n}\n\nstruct PerThread;\n\nunsafe impl GlobalAlloc for PerThread {\n    unsafe fn alloc(&amp;self, layout: Layout) -&gt; *mut u8 {\n        let _ = ALLOCS_ON_THIS_THREAD.try_with(|c| c.set(c.get() + 1));\n        unsafe { System.alloc(layout) }\n    }\n    unsafe fn dealloc(&amp;self, ptr: *mut u8, layout: Layout) {\n        unsafe { System.dealloc(ptr, layout) }\n    }\n}\n\n#[global_allocator]\nstatic A: PerThread = PerThread;\n\n/// Run `f` and return how many allocations *this thread* made.\nfn count_allocs&lt;R&gt;(f: impl FnOnce() -&gt; R) -&gt; (R, u64) {\n    let before = ALLOCS_ON_THIS_THREAD.with(Cell::get);\n    let out = f();\n    (out, ALLOCS_ON_THIS_THREAD.with(Cell::get) - before)\n}\n\nfn checksum(bytes: &amp;[u8]) -&gt; u32 {\n    bytes.iter().fold(0u32, |acc, &amp;b| acc.rotate_left(5) ^ u32::from(b))\n}\n\nfn checksum_hex(bytes: &amp;[u8]) -&gt; String {\n    format!(\"{:08x}\", checksum(bytes))\n}\n\nfn main() {\n    let data = vec![7u8; 4096];\n\n    let (_, n) = count_allocs(|| checksum(&amp;data));\n    println!(\"checksum:     {n} allocations\");\n    assert_eq!(n, 0, \"the hot path must not allocate\");\n\n    let (_, n) = count_allocs(|| checksum_hex(&amp;data));\n    println!(\"checksum_hex: {n} allocation(s)\");\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn checksum_does_not_allocate() {\n        let data = [1u8; 64];\n        let (_, n) = count_allocs(|| checksum(&amp;data));\n        assert_eq!(n, 0);\n    }\n}</code></pre>\n<pre><code class=\"language-text\">checksum:     0 allocations\nchecksum_hex: 1 allocation(s)</code></pre>\n<p>The pair of lines is the point. <code>checksum</code> is a pure computation over a slice and must never allocate, and the program asserts it. <code>checksum_hex</code> wraps the same call in a <code>format!</code>, which costs one allocation. The unit test at the bottom of the file is the pattern to copy: assert that a function on a latency-critical path makes zero allocations, and the build fails the day someone adds a <code>to_string()</code> or a <code>collect::&lt;Vec&lt;_&gt;&gt;()</code> to it. Allocation regressions are among the most common performance bugs and among the least visible in review, because the diff looks innocent.</p>\n<p>One caveat applies to this design: the counter sees only allocations made by the thread running the closure. If the function spawns a thread or hands work to a pool, those allocations are counted elsewhere. For code that stays on one thread, which includes most hot-path functions, it is exact.</p>\n<h2>What the tracking costs</h2>\n<p>An obvious worry is that wrapping every allocation in atomic operations slows the program. The cost is easy to measure. The program below makes two million <code>Box</code> allocations and frees, with the counting turned off and on in alternation, and times each pass.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::alloc::{GlobalAlloc, Layout, System};\nuse std::sync::atomic::{AtomicUsize, Ordering::Relaxed};\nuse std::time::Instant;\n\nstruct Counting;\nstatic COUNT: AtomicUsize = AtomicUsize::new(0);\nstatic ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);\n\nunsafe impl GlobalAlloc for Counting {\n    unsafe fn alloc(&amp;self, layout: Layout) -&gt; *mut u8 {\n        if ENABLED.load(Relaxed) {\n            COUNT.fetch_add(1, Relaxed);\n        }\n        unsafe { System.alloc(layout) }\n    }\n    unsafe fn dealloc(&amp;self, ptr: *mut u8, layout: Layout) {\n        unsafe { System.dealloc(ptr, layout) }\n    }\n}\n\n#[global_allocator]\nstatic A: Counting = Counting;\n\nfn churn() -&gt; usize {\n    let mut total = 0;\n    for i in 0..2_000_000u32 {\n        let b = Box::new(i);\n        total += *std::hint::black_box(b) as usize &amp; 1;\n    }\n    total\n}\n\nfn main() {\n    churn(); // warm up\n    for enabled in [false, true, false, true] {\n        ENABLED.store(enabled, Relaxed);\n        let start = Instant::now();\n        churn();\n        println!(\"counter {:&lt;8} {:&gt;8.2?} per 2M alloc+free pairs\", if enabled { \"enabled\" } else { \"disabled\" }, start.elapsed());\n    }\n}</code></pre>\n<pre><code class=\"language-text\">counter disabled  51.99ms per 2M alloc+free pairs\ncounter enabled   52.68ms per 2M alloc+free pairs\ncounter disabled  51.75ms per 2M alloc+free pairs\ncounter enabled   51.78ms per 2M alloc+free pairs</code></pre>\n<p>The difference is within noise: about 26 nanoseconds per pair either way, which is the cost of the system allocator itself. An uncontended atomic add is a handful of cycles next to a <code>malloc</code>. That holds for a single thread. With many threads hammering one counter, the cache line holding it bounces between cores and the cost rises sharply; a service that allocates millions of times a second across all cores should shard the counters per thread or per CPU and add them up when read. The per-thread version earlier in this guide has exactly that property.</p>\n<h2>An allocator that says no</h2>\n<p>A custom allocator can enforce a rule rather than just observe. The next one refuses any allocation that would push live bytes past a limit, by returning a null pointer, which is the documented way to signal failure. Normal code then aborts with \"memory allocation failed\", but code that uses the fallible APIs, <code>try_reserve</code> on collections, gets an <code>Err</code> it can handle. That is the right way to treat a length that comes from the network:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use std::alloc::{GlobalAlloc, Layout, System};\nuse std::sync::atomic::{AtomicUsize, Ordering::Relaxed};\n\n/// Refuses any allocation that would push live bytes past a limit.\nstruct Capped {\n    live: AtomicUsize,\n    limit: AtomicUsize,\n}\n\nunsafe impl GlobalAlloc for Capped {\n    unsafe fn alloc(&amp;self, layout: Layout) -&gt; *mut u8 {\n        let after = self.live.fetch_add(layout.size(), Relaxed) + layout.size();\n        if after &gt; self.limit.load(Relaxed) {\n            self.live.fetch_sub(layout.size(), Relaxed);\n            return std::ptr::null_mut();\n        }\n        let ptr = unsafe { System.alloc(layout) };\n        if ptr.is_null() {\n            self.live.fetch_sub(layout.size(), Relaxed);\n        }\n        ptr\n    }\n\n    unsafe fn dealloc(&amp;self, ptr: *mut u8, layout: Layout) {\n        unsafe { System.dealloc(ptr, layout) };\n        self.live.fetch_sub(layout.size(), Relaxed);\n    }\n}\n\n#[global_allocator]\nstatic A: Capped = Capped { live: AtomicUsize::new(0), limit: AtomicUsize::new(usize::MAX) };\n\n/// Parse a length-prefixed message, refusing to trust the length.\nfn read_message(declared_len: usize) -&gt; Result&lt;Vec&lt;u8&gt;, String&gt; {\n    let mut buf = Vec::new();\n    buf.try_reserve_exact(declared_len)\n        .map_err(|_| format!(\"refusing to allocate {declared_len} bytes for one message\"))?;\n    buf.resize(declared_len.min(16), 0xAB);\n    Ok(buf)\n}\n\nfn main() {\n    // Allow 1 MiB above whatever the runtime already holds.\n    A.limit.store(A.live.load(Relaxed) + (1 &lt;&lt; 20), Relaxed);\n\n    println!(\"small message: {:?}\", read_message(64).map(|m| m.len()));\n    println!(\"hostile length: {:?}\", read_message(usize::MAX / 2).map(|m| m.len()));\n    println!(\"just over the cap: {:?}\", read_message(2 &lt;&lt; 20).map(|m| m.len()));\n}</code></pre>\n<pre><code class=\"language-text\">small message: Ok(16)\nhostile length: Err(\"refusing to allocate 9223372036854775807 bytes for one message\")\njust over the cap: Err(\"refusing to allocate 2097152 bytes for one message\")</code></pre>\n<p>The first message is fine. The second declares a length near the maximum a 64-bit address space allows, the kind of value a malicious or corrupted frame carries. <code>try_reserve_exact</code> reports failure instead of aborting the process, so the parser can reject the frame and move on. The third is a plausible length that exceeds the 1 MiB budget the program set for itself. Without <code>try_reserve</code>, <code>Vec::with_capacity(declared_len)</code> on the hostile value would abort the whole server. This is a security property worth testing: a test that installs a capped allocator and feeds the parser oversized lengths proves that no input can make it allocate more than the cap before validating.</p>\n<p>One honest limitation: only code that calls the fallible APIs benefits. Ordinary <code>Vec::push</code> and <code>String</code> growth still abort on failure, because Rust's infallible collection APIs treat out-of-memory as fatal. A cap in the allocator therefore works best as a test tool and as a guard around parsing code written with <code>try_reserve</code>, not as a general mechanism to survive running out of memory.</p>\n<h2>Alignment and zeroed memory</h2>\n<p>Two details of the contract trip people who write their own. The <code>alignment</code> in a <code>Layout</code> is a requirement, not a hint, and <code>Layout</code> guarantees it is a power of two. Types such as SIMD vectors or page-sized buffers can need 16, 64 or 4096 bytes, which is why the bump allocator rounds the address and not just the offset. And <code>alloc_zeroed</code>, which backs <code>vec![0; n]</code>, has a default implementation that calls <code>alloc</code> and writes zeros. The system allocator can often get zeroed pages straight from the operating system for large requests, with no write at all, so a wrapper that does not forward <code>alloc_zeroed</code> to <code>System.alloc_zeroed</code> turns a lazy, nearly free <code>vec![0u8; 1 &lt;&lt; 30]</code> into a gigabyte of real writes. Forward all four methods unless you have a reason not to.</p>\n<h2>The trait you cannot use yet</h2>\n<p><code>GlobalAlloc</code> is the stable interface, and it has limits: one allocator for the whole program, no way to pass an allocator to a single <code>Vec</code>, and no way to ask for the size actually allocated. The <code>Allocator</code> trait and the <code>Vec::new_in</code> family fix that and are still unstable, so using them requires a nightly toolchain. If you want a per-collection arena on stable today, use a crate that provides its own arena types, such as <code>bumpalo</code>, which hands out references tied to the arena's lifetime.</p>\n<h2>Replacing the allocator for performance</h2>\n<p>Most programs never need to write an allocator, but many benefit from swapping one in. The system allocator on some platforms is slow under multithreaded contention or fragments badly in long-running services. Third-party allocators such as <code>mimalloc</code> and <code>tikv-jemallocator</code> install in three lines:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use mimalloc::MiMalloc;\n\n#[global_allocator]\nstatic GLOBAL: MiMalloc = MiMalloc;</code></pre>\n<p>Whether it helps is an empirical question with a measurable answer. Build once with each, run the same <a href=\"/library/load-test-rust-service\">load test</a> at the same request rate, and compare tail latency and resident memory. Allocator changes often move the 99th percentile more than the median, and they trade memory for speed in different ways, so the numbers you read must come from your workload. The tracking wrapper from this guide composes with any of them: wrap <code>MiMalloc</code> instead of <code>System</code> and keep the counters.</p>\n<h2>When you should not write one</h2>\n<p>Do not replace the global allocator to fix a problem you have not measured. For a leak, a heap profiler such as <code>dhat</code> or the platform's tooling points at the allocating line directly, with no custom <code>unsafe</code> code. For a per-request arena, use <code>bumpalo</code>. For a memory cap in a test, the tracking allocator above is enough, and it takes minutes. On embedded targets with no operating system, the situation inverts: there is no <code>System</code>, and you supply the allocator, usually a small crate such as <code>embedded-alloc</code> or <code>linked-list-allocator</code>, initialized with a region of RAM at startup. Whatever the case, keep to the rules that make allocators safe: no allocation, no locks that can deadlock, no panics, and a comment justifying every <code>unsafe</code>. For the language-level story of what you are bypassing, see <a href=\"/library/rust-box-rc-arc\">Box, Rc and Arc in Rust</a>, and for memory problems in services, <a href=\"/library/rust-memory-usage-leaks\">memory usage and finding leaks</a>.</p>","date_published":"2026-11-03T09:40:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/rust-firmware-size-optimization","url":"https://rusthacks.dev/library/rust-firmware-size-optimization","title":"Rust Firmware Size Optimization Down to Tens of Kilobytes","summary":"Microcontrollers have kilobytes, not megabytes. Most of the savings come from five profile settings, avoiding the formatting machinery and finding the large symbols with cargo-bloat.","content_html":"<p>A microcontroller with 64 KiB of flash does not have room for a surprise. A firmware that fit last week stops fitting after someone adds a line that prints a temperature, and the linker reports an overflow of a few kilobytes with no hint about which line did it. Shrinking firmware is detective work: measure, find the biggest consumer, replace it with something smaller, measure again. The detective part is easy to get wrong because the intuitive suspects, the optimization level and your own code, are rarely the culprits.</p>\n<p>This guide runs that process on a real firmware. It starts at 1,160 bytes, adds the features that real firmware adds, watches the size jump to 32,128 bytes because of one innocent-looking <code>write!</code>, finds the cause with a symbol listing, and brings it back down to 1,368 bytes without losing the feature. Every number below was measured on Rust 1.99 with edition 2024, building for <code>thumbv7em-none-eabihf</code> (Cortex-M4F) with <code>cortex-m-rt</code> 0.7.7, <code>cortex-m</code> 0.7.10 and <code>panic-halt</code> 1.0.0. The code was not run on a board, since size depends on what is compiled, not on running it.</p>\n<h2>What counts as size</h2>\n<p>Three numbers get called \"size\", and mixing them up wastes hours. The ELF file that Cargo produces includes debug information, symbol tables and section headers. None of that goes to the chip. The flash footprint is the code, read-only data and initial values of variables: in the <code>llvm-size</code> output, <code>text</code> plus <code>data</code>. The RAM footprint is <code>data</code> plus <code>bss</code>, plus the stack and any heap you chose to add.</p>\n<pre><code class=\"language-bash\">rustup component add llvm-tools\n$(rustc --print sysroot)/lib/rustlib/aarch64-apple-darwin/bin/llvm-size target/thumbv7em-none-eabihf/release/fwsize</code></pre>\n<p>Replace <code>aarch64-apple-darwin</code> with your host's triple. The <code>rust-size</code> command from the <code>cargo-binutils</code> package is a shorter way to run the same tool. For this guide, the number reported is the first column, <code>text</code>, which on Cortex-M includes the 1,024-byte interrupt vector table that the runtime places at the start of flash. Subtract it if you want to see only your code; it is constant for a given chip.</p>\n<h2>The experiment: one firmware, several features</h2>\n<p>The test firmware reads an analog sample from a register and writes a pin. Three Cargo features add the things real firmware adds. <code>index</code> smooths the readings with indexed array access. <code>fmt</code> reports a temperature as text with <code>write!</code> and a floating-point conversion, and <code>fmtint</code> does the same with an integer. A feature called <code>manual</code> prints the same temperature using hand-written digit conversion. The build script sets the optimization level through environment variables so the same source can be measured under different profiles.</p>\n<p>The core of the formatting variant is ordinary Rust that any developer would write:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use core::fmt::Write;\n\npub struct Uart;\n\nimpl Write for Uart {\n    fn write_str(&amp;mut self, s: &amp;str) -&gt; core::fmt::Result {\n        for b in s.bytes() {\n            unsafe { core::ptr::write_volatile(0x4001_1004 as *mut u32, u32::from(b)) };\n        }\n        Ok(())\n    }\n}\n\npub fn temperature(raw: u32) {\n    let celsius = raw as f32 * 0.0806 - 50.0;\n    let _ = write!(Uart, \"temp {:.1} C (raw {})\\n\", celsius, raw);\n}</code></pre>\n<p>That is eighteen lines, and it implements the standard <code>core::fmt::Write</code> trait on a serial port so that <code>write!</code> works. It looks like the kind of code you would write in the first hour of a project.</p>\n<h2>The results</h2>\n<pre><code class=\"language-text\">plain, opt-level 3                           1184\nplain, opt-level s                           1160\nplain, opt-level z                           1168\nindexing, opt-level s                        1436\nfmt + float, opt-level s                     32128\nfmt + float, opt-level z                     32120\nfmt + float, z, lto, cgu=1                   17364\ninteger-only write!, opt-level s             4148\nhand-written digits, opt-level s             1368</code></pre>\n<p>The numbers are bytes of flash. Read them as a sequence of lessons, because each row teaches one thing.</p>\n<h3>The optimization level barely matters here</h3>\n<p>The first three rows differ by 24 bytes. Choosing <code>opt-level = \"s\"</code> over 3 saved 24 bytes, and <code>\"z\"</code>, the most aggressive size setting, was larger than <code>\"s\"</code> by 8. On a program with almost no code, there is nothing for the optimizer to shrink. The optimization level matters on large programs with real loops and inlining, and even there it is a tuning step, not the solution. If someone's answer to \"the firmware is too big\" is \"set <code>opt-level = z</code>\", this table is the reply: measure what is big first. The same table also shows why: when a program grew by thirty kilobytes, switching from <code>s</code> to <code>z</code> recovered 8 bytes.</p>\n<h3>Bounds checks have a price</h3>\n<p>The fourth row adds a smoothing function with three indexed array accesses. The firmware grew by 276 bytes. Each <code>samples[i % 16]</code> can panic if the index is out of range, so the compiler emits a comparison and a branch to a panic routine for each one. The panic routine takes the file and line number as arguments, which means the string constants for the location are stored in flash too. Rust code with many indexing expressions pays that per site. You can remove the check where you can prove it unnecessary: iterate with <code>iter()</code> instead of indexing, use <code>get(i)</code> and handle <code>None</code>, or use <code>chunks_exact</code>. Avoid <code>get_unchecked</code> unless profiling proves it matters, since it trades 20 bytes for undefined behavior if you are wrong.</p>\n<h3>One <code>write!</code> costs 31 kilobytes</h3>\n<p>The fifth row is the one that ruins projects. Adding a function that formats one floating-point number grows the firmware from 1,160 bytes to 32,128. The code you wrote is a few dozen bytes. The rest is the standard formatting machinery, linked in because <code>{:.1}</code> on a float needs to turn a binary number into decimal digits correctly, and correct float printing is an algorithm, two algorithms in fact, with large lookup tables.</p>\n<p>You do not have to guess which parts. The <code>llvm-nm</code> tool, run with <code>--size-sort</code>, lists symbols by size, and the biggest entries tell the story:</p>\n<pre><code class=\"language-bash\">$(rustc --print sysroot)/lib/rustlib/aarch64-apple-darwin/bin/llvm-nm --size-sort --demangle --print-size target/thumbv7em-none-eabihf/release/fwsize | tail -8</code></pre>\n<pre><code class=\"language-text\">08000040 000003c0 R __INTERRUPTS\n08001cdc 00000490 T core::num::imp::flt2dec::strategy::grisu::format_exact_opt\n08001658 000004ea T core::str::count::do_count_chars\n08007410 00000510 R core::num::imp::flt2dec::strategy::grisu::CACHED_POW10\n0800216c 00000862 T core::num::imp::flt2dec::strategy::grisu::format_shortest_opt\n0800538e 00000a78 T core::num::imp::flt2dec::strategy::dragon::mul_pow10\n080029ce 0000146c T core::num::imp::flt2dec::strategy::dragon::format_exact\n08003e3a 00001554 T core::num::imp::flt2dec::strategy::dragon::format_shortest</code></pre>\n<p>The second column is the symbol's size in hexadecimal. The two largest are <code>dragon::format_shortest</code> (0x1554, 5,460 bytes) and <code>dragon::format_exact</code> (0x146c, 5,228 bytes), followed by <code>dragon::mul_pow10</code> (2,680 bytes), the <code>grisu</code> variants (2,146 and 1,168 bytes) and a table of cached powers of ten (1,296 bytes). Grisu is the fast path for float to decimal conversion and Dragon is the exact fallback when Grisu cannot decide. The standard library includes both so that every float prints correctly, and your firmware pays for both whether or not it ever reaches the fallback. This is the single most common cause of an embedded Rust binary that is far larger than its source suggests.</p>\n<h3>Link-time optimization recovers some of it</h3>\n<p>Row six repeats the formatting build with <code>lto = true</code> and <code>codegen-units = 1</code>, and the size falls from 32,120 to 17,364 bytes, a 46 percent drop. With a single compilation unit and whole-program optimization, the compiler can see that parts of the formatting code are unreachable from your use and discard them. It is the cheapest big win available, it costs only build time, and it belongs in every firmware release profile:</p>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[profile.release]\nopt-level = \"s\"\nlto = true\ncodegen-units = 1\npanic = \"abort\"\ndebug = true      # stays in the ELF, never reaches flash</code></pre>\n<p>Note <code>debug = true</code>. Debug information does not increase the flash footprint, as the first section explained, and it lets a debugger show source lines. Do not strip it from the development profile to chase size.</p>\n<h3>Integers are cheaper than floats, and nothing is cheaper than no formatting</h3>\n<p>Row seven formats only an integer with <code>write!(Uart, \"raw {}\\n\", raw)</code>, and the size falls to 4,148 bytes. The integer path of <code>core::fmt</code> is small compared to the float path but still brings the <code>Formatter</code>, the padding logic and the <code>Arguments</code> machinery: about 3 KiB over the plain program. For many devices that is acceptable, and it keeps your code readable. If it is not, the last row prints the same temperature with no <code>core::fmt</code> at all.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">/// Print tenths of a degree as \"-12.3\" with no `core::fmt` and no floats.\npub fn temperature(raw: u32) {\n    let tenths = (raw as i32).wrapping_mul(806) / 1000 - 500; // 0.0806 C per count, minus 50 C\n    let mut n = tenths.unsigned_abs();\n    if tenths &lt; 0 {\n        put(b'-');\n    }\n    let mut digits = [0u8; 10];\n    let mut len = 0;\n    for slot in digits.iter_mut() {\n        *slot = b'0' + (n % 10) as u8;\n        len += 1;\n        n /= 10;\n        if n == 0 &amp;&amp; len &gt;= 2 {\n            break;\n        }\n    }\n    for (k, d) in digits.iter().take(len).rev().enumerate() {\n        if k + 1 == len {\n            put(b'.');\n        }\n        put(*d);\n    }\n    put(b'\\n');\n}</code></pre>\n<p>It computes in fixed point: counts times 806 over 1000 gives tenths of a degree as an integer, with no floating point anywhere, and digits are produced by repeated division by ten. The result is 1,368 bytes, an increase of only 208 over the plain firmware, against 30,968 for the version that used <code>{:.1}</code>.</p>\n<h3>The first version of that function was 3,920 bytes</h3>\n<p>That 1,368 deserves a footnote, because the first attempt at the manual formatter measured 3,920 bytes, almost three times larger, and the reason is the lesson of the \"indexing\" row in disguise. The first draft wrote <code>digits[len] = ...</code> and sliced <code>digits[..len]</code>. Both can panic, and a panic from an out-of-range index is built with <code>core::fmt::Arguments</code>, which references the formatting code for <code>usize</code>. One indexing expression in a function was enough to link in the integer formatting path that the function was written to avoid. Replacing the indexing with iterators, <code>iter_mut()</code> and <code>take(len)</code>, which cannot go out of range, removed every panic site, and the size fell to 1,368. The same applies to <code>unwrap</code>, <code>expect</code>, slice ranges and arithmetic when overflow checks are on. If you are fighting for bytes, search the disassembly for calls to <code>core::panicking</code>, or list the symbols that mention <code>fmt</code>, and trace each one back to the line that required it.</p>\n<h2>The tools for finding out</h2>\n<p>Three commands cover most investigations, and you should use them before editing anything.</p>\n<p><code>llvm-size -A</code> prints every section with its size, which shows whether code, constant data or RAM is the problem. <code>llvm-nm --size-sort --demangle --print-size</code>, shown above, ranks symbols; the tail is the answer. And <code>cargo bloat --release --crates</code> (from the <code>cargo-bloat</code> crate) groups the same information by crate, which answers \"is it my code, <code>core</code>, or this one dependency?\" in one table. In a typical oversized firmware the crate table shows <code>core</code> or <code>compiler_builtins</code> near the top, and the next step is to ask which of your lines pulled it in. Work from the biggest symbol down, change one thing, and rebuild; the measurement loop takes seconds.</p>\n<h2>Monomorphization: does a trait object save space?</h2>\n<p>Generic functions are compiled once for every type they are used with, and the usual advice for shrinking a binary is to replace a generic with a trait object so that one copy serves all types. That advice is sometimes right and often untested, so test it. The experiment calls one small function with four element types, <code>u8</code>, <code>u16</code>, <code>u32</code> and <code>bool</code>, in two forms: a generic <code>weighted&lt;T: Copy + Into&lt;u32&gt;&gt;</code> marked <code>#[inline(never)]</code>, so that each copy stays visible, and a version taking <code>&amp;dyn Fn(usize) -&gt; u32</code> that fetches each element through a closure.</p>\n<pre><code class=\"language-text\">none       1160\ngeneric    1416\ndynamic    1600</code></pre>\n<p>The generic version added 256 bytes to the baseline, about 64 bytes per instantiation. The trait-object version added 440, nearly twice as much, because each call site now builds a closure and a vtable and the function body has an indirect call inside its loop. The abstraction that was supposed to save space cost more than the duplication it removed. The rule that survives is narrower: a trait object pays off when the function body is large, hundreds of bytes or more, and the per-type difference is small. A 64-byte function copied four times is cheaper than any indirection. Measure with the symbol listing, which shows each monomorphized copy as its own entry (<code>weighted&lt;u8&gt;</code>, <code>weighted&lt;u16&gt;</code> and so on), and only then decide.</p>\n<h2>Put a budget in CI</h2>\n<p>Size regressions arrive one innocent commit at a time, and the commit that crosses the line is never the one that caused the problem. A budget turns the slow drift into a failing check. The script below builds the release binary, reads the <code>text</code> column, and fails if it exceeds a limit you set a little above today's number.</p>\n<pre data-filename=\"ci/check-size.sh\"><code class=\"language-bash\">#!/bin/sh\nset -eu\n\nLIMIT=${1:?usage: check-size.sh &lt;max-bytes&gt;}\nBIN=target/thumbv7em-none-eabihf/release/fwsize\nSIZE=$(rustc --print sysroot)/lib/rustlib/$(rustc -vV | sed -n 's/host: //p')/bin/llvm-size\n\ncargo build --release --quiet\nused=$(\"$SIZE\" \"$BIN\" | awk 'NR==2 {print $1 + $2}')   # text + data = flash footprint\n\necho \"flash used: $used bytes (limit $LIMIT)\"\nif [ \"$used\" -gt \"$LIMIT\" ]; then\n    echo \"over budget by $((used - LIMIT)) bytes\" &gt;&amp;2\n    exit 1\nfi</code></pre>\n<p>Run against the plain firmware with a generous and then a tight limit, it behaves like this:</p>\n<pre><code class=\"language-text\">flash used: 1184 bytes (limit 2000)\n\nflash used: 1184 bytes (limit 1000)\nover budget by 184 bytes</code></pre>\n<p>The second run exits with status 1. Wire it into the pipeline and a change that adds a float format fails loudly with the overage, on the pull request that caused it, instead of surfacing at a linker error on release day. Set the limit about 5 percent above the current size so ordinary work does not trip it, and move the limit down when you win bytes back. Printing the number on every run, passing or not, gives you a free history in the CI logs. For a more detailed view on pull requests, attach the output of <code>cargo bloat --release --crates</code> as a comment, so reviewers see which crate grew.</p>\n<h2>The order to work in</h2>\n<p>Compress the work into a fixed order and you waste less time. First turn on <code>lto = true</code>, <code>codegen-units = 1</code> and <code>panic = \"abort\"</code>, which is five minutes of work and, as the formatting rows showed, can remove nearly half of an oversized binary. Second, list the biggest symbols and look at the top ten; if <code>core::fmt</code> or <code>core::num::flt2dec</code> appears, you have found the budget. Third, remove or replace the culprit: drop float formatting, move text to the host with <code>defmt</code>, or hand-write the conversion. Fourth, hunt panic sites, because each one carries a location string and may drag formatting along, as the 3,920-byte formatter showed. Only fifth, tune <code>opt-level</code>, and measure <code>s</code> against <code>z</code> and <code>3</code> rather than assuming, since the best choice differs for code that is mostly branches and code that is mostly arithmetic.</p>\n<h2>Habits that keep a firmware small</h2>\n<ul><li>Keep floating-point formatting off the device. Send raw integers and format on the host, or use fixed-point as above.</li><li>Prefer <code>defmt</code> for logging. It sends an index and the arguments in binary and leaves the text on your computer, so a log statement costs a few bytes of flash instead of a format string plus the formatting code.</li><li>Be wary of generic functions called with many types. Each type gets its own copy (monomorphization), and a function used with eight types is eight functions. When the body is large and the type difference is tiny, take a <code>&amp;dyn Trait</code> or convert to a common type early.</li><li>Choose <code>panic-halt</code> or <code>panic-abort</code> for release builds. A panic handler that formats a message with the location pulls in formatting code, and <code>panic-semihosting</code> is a debugging tool, not a shipping one.</li><li>Review dependencies before adding them. A crate that parses JSON or does date math can add tens of kilobytes. Check with <code>cargo bloat</code> after adding one.</li><li>Enable <code>lto = true</code> and <code>codegen-units = 1</code> and keep them on. They cost nothing at runtime.</li></ul>\n<p>On nightly there is more to try. <code>-Z build-std</code> rebuilds <code>core</code> with your settings, and <code>panic_immediate_abort</code> removes the panic message machinery entirely. Those options were not measured here, and they require a nightly toolchain, so adopt them only once the stable options are exhausted.</p>\n<h2>When size is the wrong thing to optimize</h2>\n<p>If the firmware fits with room to spare, stop. A 20 percent smaller binary that took a week to achieve and made the formatting code harder to read is a bad trade on a chip with 512 KiB of flash. Optimize size when you hit a limit, when a smaller chip would cut the bill of materials across thousands of units, or when an over-the-air update has to fit a tight radio budget. Optimize speed or power instead when those are the constraints, since the settings do not always agree: <code>opt-level = 3</code> can be faster and larger, and <code>wfi</code> sleeping saves more battery than any code size win. For the groundwork these numbers rest on, see <a href=\"/library/embedded-rust-getting-started\">embedded Rust from blinking an LED to real firmware</a>.</p>\n<p>One last caution about the budget number itself. It is only comparable between builds that use the same toolchain, the same dependency versions and the same profile. A compiler upgrade can move the total by a few hundred bytes in either direction, so record the Rust version next to the limit and re-baseline deliberately when it changes. Pinning the toolchain in <code>rust-toolchain.toml</code> makes that a decision you take on purpose, not a failure that appears when a new stable release lands on a Tuesday.</p>","date_published":"2026-11-03T09:38:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/rust-bevy-ecs-intro","url":"https://rusthacks.dev/library/rust-bevy-ecs-intro","title":"Game Development in Rust With Bevy and Its ECS Model","summary":"Bevy organizes a game as data and functions. Entities are IDs, components are plain structs and systems are ordinary functions that query for the components they need.","content_html":"<p>Bevy is a game engine, and the part of it you can use on its own is its entity component system: <code>bevy_ecs</code>, a library that stores your data in a way that makes queries fast and lets independent functions run in parallel without locks. Games are the obvious customer. Simulations, schedulers, editors and anything with thousands of things that each have a different mix of properties are the others. This guide teaches the model with programs that run headless, with no window and no renderer: entities, components, systems and queries; filters, resources and change detection; messages and observers; how the scheduler decides what can run in parallel (with a measurement); the cost of structural changes; and the errors you will meet. Everything compiled and ran on Rust 1.99 with <code>bevy_ecs</code> 0.20.1 (Bevy 0.20.1 is the current release as of this writing). Bevy's API moves between releases, so the version matters: pin it, and read the migration guide before upgrading.</p>\n<h2>The model in one paragraph</h2>\n<p>An <strong>entity</strong> is an id and nothing else. A <strong>component</strong> is a piece of plain data attached to an entity (a position, a health value, a marker). A <strong>system</strong> is an ordinary function whose arguments say what data it needs; the scheduler inspects the arguments to learn which components each system reads and writes. A <strong>query</strong> is how a system asks for \"every entity that has a Position and a Velocity\". Contrast this with object-oriented design, where a <code>Ship</code> class owns its position and a method moves it: in an ECS a ship is just whichever entity happens to have the components that movement, rendering and AI systems look for. Behaviour is added by adding a component, not by subclassing. The storage turns \"all positions\" into a contiguous column, which is what makes iteration over a million entities fast and what lets the scheduler prove that two systems touch different data.</p>\n<h2>Set up</h2>\n<pre data-filename=\"Cargo.toml\"><code class=\"language-toml\">[package]\nname = \"ecs-lab\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nbevy_ecs = \"0.20\"</code></pre>\n<p>That is the whole dependency. Full Bevy adds windowing, rendering, assets and input as plugins; they all sit on top of this crate. Starting with <code>bevy_ecs</code> alone keeps compile times short (a few seconds for an incremental build) and keeps examples testable in plain <code>cargo test</code>, with no GPU.</p>\n<h2>Your first world</h2>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use bevy_ecs::prelude::*;\n\n#[derive(Component, Debug)]\nstruct Position { x: f32, y: f32 }\n\n#[derive(Component, Debug)]\nstruct Velocity { x: f32, y: f32 }\n\n#[derive(Component)]\nstruct Name(&amp;'static str);\n\nfn movement(mut query: Query&lt;(&amp;mut Position, &amp;Velocity)&gt;) {\n    for (mut pos, vel) in &amp;mut query {\n        pos.x += vel.x;\n        pos.y += vel.y;\n    }\n}\n\nfn report(query: Query&lt;(&amp;Name, &amp;Position)&gt;) {\n    for (name, pos) in &amp;query {\n        println!(\"{} at ({:.1}, {:.1})\", name.0, pos.x, pos.y);\n    }\n}\n\nfn main() {\n    let mut world = World::new();\n    world.spawn((Name(\"ship\"), Position { x: 0.0, y: 0.0 }, Velocity { x: 1.0, y: 0.5 }));\n    world.spawn((Name(\"rock\"), Position { x: 5.0, y: 5.0 }));\n\n    let mut schedule = Schedule::default();\n    schedule.add_systems((movement, report).chain());\n\n    for tick in 1..=2 {\n        println!(\"-- tick {tick}\");\n        schedule.run(&amp;mut world);\n    }\n}\n</code></pre>\n<pre><code class=\"language-text\">-- tick 1\nship at (1.0, 0.5)\nrock at (5.0, 5.0)\n-- tick 2\nship at (2.0, 1.0)\nrock at (5.0, 5.0)</code></pre>\n<p>Everything in an ECS program fits this shape. A <code>World</code> owns all the data. <code>world.spawn(( ... ))</code> creates an entity from a tuple of components, called a bundle: the ship has three components, the rock only two, and no class declares that. The <code>movement</code> system takes <code>Query&lt;(&amp;mut Position, &amp;Velocity)&gt;</code>, so it visits every entity that has both, and the rock, which has no <code>Velocity</code>, is skipped. The <code>report</code> system asks for a name and a position and sees both entities. A <code>Schedule</code> runs the systems; <code>.chain()</code> forces them to run in order, so that the report always sees the moved positions. Without it the scheduler may run them in either order or in parallel, because nothing says one depends on the other.</p>\n<p>One practical warning from running this code: the order in which a query returns entities is not specified. When the same program was built against <code>bevy_ecs</code> 0.18 the rock was reported before the ship, and against 0.20 the ship came first. If your output depends on the order, sort explicitly or carry an index component.</p>\n<h2>Filters, resources, commands and change detection</h2>\n<p>Real programs need more than \"every entity with these components\". Filters narrow a query without borrowing the data: <code>With&lt;T&gt;</code> and <code>Without&lt;T&gt;</code> test for presence, <code>Changed&lt;T&gt;</code> matches components modified since the system last ran. <strong>Resources</strong> are singletons stored in the world, such as a score or a clock. <strong>Commands</strong> queue structural changes (spawn, despawn, insert, remove) to be applied safely after the system finishes, since changing which entities exist while iterating would invalidate the iterators. This program wires them together: poison drains health, a reaper despawns dead enemies and adds to the score, and a reporter prints the state.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use bevy_ecs::prelude::*;\n\n#[derive(Component, Debug)]\nstruct Health(i32);\n\n#[derive(Component)]\nstruct Enemy;\n\n#[derive(Component)]\nstruct Player;\n\n#[derive(Component)]\nstruct Poisoned;\n\n#[derive(Resource, Default)]\nstruct Score(u32);\n\n#[derive(Resource)]\nstruct Tick(u32);\n\nfn setup(mut commands: Commands) {\n    commands.spawn((Player, Health(100)));\n    commands.spawn((Enemy, Health(30)));\n    commands.spawn((Enemy, Health(50), Poisoned));\n}\n\nfn poison(mut query: Query&lt;&amp;mut Health, With&lt;Poisoned&gt;&gt;) {\n    for mut hp in &amp;mut query {\n        hp.0 -= 20;\n    }\n}\n\nfn reap(mut commands: Commands, mut score: ResMut&lt;Score&gt;, query: Query&lt;(Entity, &amp;Health), (With&lt;Enemy&gt;, Changed&lt;Health&gt;)&gt;) {\n    for (entity, hp) in &amp;query {\n        if hp.0 &lt;= 0 {\n            score.0 += 10;\n            commands.entity(entity).despawn();\n        }\n    }\n}\n\nfn describe(tick: Res&lt;Tick&gt;, score: Res&lt;Score&gt;, players: Query&lt;&amp;Health, With&lt;Player&gt;&gt;, enemies: Query&lt;&amp;Health, (With&lt;Enemy&gt;, Without&lt;Poisoned&gt;)&gt;, poisoned: Query&lt;&amp;Health, With&lt;Poisoned&gt;&gt;) {\n    println!(\n        \"tick {}: score {}, player {:?}, healthy enemies {}, poisoned {:?}\",\n        tick.0,\n        score.0,\n        players.iter().map(|h| h.0).collect::&lt;Vec&lt;_&gt;&gt;(),\n        enemies.iter().count(),\n        poisoned.iter().map(|h| h.0).collect::&lt;Vec&lt;_&gt;&gt;()\n    );\n}\n\nfn advance(mut tick: ResMut&lt;Tick&gt;) {\n    tick.0 += 1;\n}\n\nfn main() {\n    let mut world = World::new();\n    world.insert_resource(Score::default());\n    world.insert_resource(Tick(0));\n\n    let mut startup = Schedule::default();\n    startup.add_systems(setup);\n    startup.run(&amp;mut world);\n\n    let mut frame = Schedule::default();\n    frame.add_systems((advance, poison, reap, describe).chain());\n    for _ in 0..4 {\n        frame.run(&amp;mut world);\n    }\n}\n</code></pre>\n<pre><code class=\"language-text\">tick 1: score 0, player [100], healthy enemies 1, poisoned [30]\ntick 2: score 0, player [100], healthy enemies 1, poisoned [10]\ntick 3: score 10, player [100], healthy enemies 1, poisoned []\ntick 4: score 10, player [100], healthy enemies 1, poisoned []</code></pre>\n<p>Follow the poisoned enemy, which started with 50 health and lost 20 per tick: 30 after tick 1, 10 after tick 2, and at tick 3 it reached -10, the reaper saw the change, despawned it and added 10 points. The reaper's query uses <code>Changed&lt;Health&gt;</code>, so it only examines enemies whose health was written this frame, which matters when thousands of enemies exist and few are being hurt. The \"healthy enemies\" count stayed at one throughout: the first enemy has no <code>Poisoned</code> marker and is never touched. The marker pattern is the ECS way to express state: instead of a boolean field checked in every system, poisoning is a component, and systems select by presence. Adding or removing it with <code>commands.entity(e).insert(Poisoned)</code> changes which systems apply to that entity from the next frame.</p>\n<h2>Messages and observers</h2>\n<p>Systems often need to tell each other that something happened. Bevy has two mechanisms, and choosing between them is the main design decision after components. <strong>Messages</strong> are buffered: a system writes them, and later systems in the same or the next frame read them in order. They suit high-volume, batch-processed signals such as damage events. <strong>Observers</strong> are triggered immediately: an event is fired and the observer function runs right then, with access to the entity it targets. They suit rare, reactive things such as \"this entity died, play the animation, drop the loot\". The program uses both: the damage message carries an amount to a target, and when health reaches zero the system triggers a <code>Died</code> event on the entity, handled by an observer that despawns it.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use bevy_ecs::prelude::*;\n\n#[derive(Component, Debug)]\nstruct Health(i32);\n\n#[derive(Component)]\nstruct Name(&amp;'static str);\n\n#[derive(Message)]\nstruct Damage {\n    target: Entity,\n    amount: i32,\n}\n\n#[derive(EntityEvent)]\nstruct Died {\n    entity: Entity,\n}\n\nfn attack(mut writer: MessageWriter&lt;Damage&gt;, query: Query&lt;(Entity, &amp;Name)&gt;) {\n    for (entity, name) in &amp;query {\n        if name.0 == \"goblin\" {\n            writer.write(Damage { target: entity, amount: 40 });\n        }\n    }\n}\n\nfn apply_damage(mut reader: MessageReader&lt;Damage&gt;, mut query: Query&lt;&amp;mut Health&gt;, mut commands: Commands) {\n    for dmg in reader.read() {\n        if let Ok(mut hp) = query.get_mut(dmg.target) {\n            hp.0 -= dmg.amount;\n            if hp.0 &lt;= 0 {\n                commands.trigger(Died { entity: dmg.target });\n            }\n        }\n    }\n}\n\nfn main() {\n    let mut world = World::new();\n    world.init_resource::&lt;Messages&lt;Damage&gt;&gt;();\n\n    world.add_observer(|died: On&lt;Died&gt;, names: Query&lt;&amp;Name&gt;, mut commands: Commands| {\n        println!(\"observer: {} died\", names.get(died.entity).map(|n| n.0).unwrap_or(\"?\"));\n        commands.entity(died.entity).despawn();\n    });\n\n    world.spawn((Name(\"goblin\"), Health(70)));\n    world.spawn((Name(\"knight\"), Health(200)));\n\n    let mut schedule = Schedule::default();\n    schedule.add_systems((attack, apply_damage).chain());\n\n    for round in 1..=3 {\n        schedule.run(&amp;mut world);\n        world.resource_mut::&lt;Messages&lt;Damage&gt;&gt;().update();\n        let alive = world.query::&lt;&amp;Name&gt;().iter(&amp;world).map(|n| n.0).collect::&lt;Vec&lt;_&gt;&gt;();\n        println!(\"round {round}: alive = {alive:?}\");\n    }\n}\n</code></pre>\n<pre><code class=\"language-text\">round 1: alive = [\"goblin\", \"knight\"]\nobserver: goblin died\nround 2: alive = [\"knight\"]\nround 3: alive = [\"knight\"]</code></pre>\n<p>Round one: the goblin took 40 of its 70 health, and both survive. Round two: it took 40 more, health reached -10, the system triggered <code>Died</code>, and the observer printed and despawned it before the round ended. Round three: only the knight is left. The line <code>world.resource_mut::&lt;Messages&lt;Damage&gt;&gt;().update()</code> advances the message buffers, which full Bevy does for you every frame; a standalone world has to do it itself or messages pile up. Note that in earlier releases buffered events were called <code>Event</code> and read with <code>EventReader</code>; if you copy older code, expect to rename those to <code>Message</code>, <code>MessageReader</code> and <code>MessageWriter</code>, and to use <code>On&lt;E&gt;</code> for observers.</p>\n<h2>How the scheduler finds parallelism</h2>\n<p>Each system declares its data access through its arguments, so the scheduler knows before running anything which components each one reads and writes. Two systems can run at the same time if neither writes something the other touches. You can see it with a deliberate experiment: five arrangements of systems that each sleep for 100 ms. If the scheduler overlaps them, the total is about 100 ms; if it serialises them, about 200 ms.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">fn run(label: &amp;str, build: impl FnOnce(&amp;mut Schedule)) {\n    let mut world = World::new();\n    world.spawn((A(0), B(0)));\n    let mut schedule = Schedule::default();\n    schedule.set_executor(bevy_ecs::schedule::MultiThreadedExecutor::new());\n    build(&amp;mut schedule);\n    let start = Instant::now();\n    schedule.run(&amp;mut world);\n    println!(\"{label:&lt;44} {:&gt;4} ms\", start.elapsed().as_millis());\n}\n\nfn main() {\n    bevy_tasks::ComputeTaskPool::get_or_init(bevy_tasks::TaskPool::default);\n    run(\"slow_a + slow_b (different components)\", |s| {\n        s.add_systems((slow_a, slow_b));\n    });\n    run(\"slow_a + slow_a_again (both write A)\", |s| {\n        s.add_systems((slow_a, slow_a_again));\n    });\n    run(\"read_a + read_a_too (both only read A)\", |s| {\n        s.add_systems((read_a, read_a_too));\n    });\n    run(\"slow_a + read_a (one writes, one reads A)\", |s| {\n        s.add_systems((slow_a, read_a));\n    });\n    run(\"slow_a + slow_b chained\", |s| {\n        s.add_systems((slow_a, slow_b).chain());\n    });\n}\n</code></pre>\n<pre><code class=\"language-text\">slow_a + slow_b (different components)        110 ms\nslow_a + slow_a_again (both write A)          216 ms\nread_a + read_a_too (both only read A)        100 ms\nslow_a + read_a (one writes, one reads A)     200 ms\nslow_a + slow_b chained                       213 ms</code></pre>\n<p>The results follow the rules exactly. Two systems that write different components ran together (109 ms). Two that both write <code>A</code> were serialised (211 ms), as were one that writes and one that reads the same component (216 ms), since a reader must not see a half-written value. Two read-only systems overlapped (109 ms). And <code>.chain()</code> serialises by request (209 ms). One setup detail: a standalone <code>bevy_ecs</code> schedule used the single-threaded executor until it was given <code>MultiThreadedExecutor</code> and the <code>multi_threaded</code> feature was enabled with a task pool created; full Bevy does all of that for you. You never write a lock, and the guarantee comes from the borrow rules applied at the system-parameter level, which is the closest thing to \"Rust's ownership model, but across functions\" in an engine.</p>\n<h2>The system parameters you will use every day</h2>\n<p>A system's arguments are its whole interface, so it pays to know the common ones. Each states what the system needs and, by doing so, what it conflicts with.</p>\n<table>\n<thead><tr><th>Parameter</th><th>Gives the system</th><th>Notes</th></tr></thead>\n<tbody>\n<tr><td><code>Query&lt;D, F&gt;</code></td><td>Every entity matching data <code>D</code> and filter <code>F</code></td><td>The workhorse; <code>&amp;T</code> reads, <code>&amp;mut T</code> writes</td></tr>\n<tr><td><code>Single&lt;D, F&gt;</code></td><td>Exactly one matching entity</td><td>The system is skipped if there are zero or more than one</td></tr>\n<tr><td><code>Res&lt;T&gt;</code>, <code>ResMut&lt;T&gt;</code></td><td>A resource, read or write</td><td>Writers to the same resource cannot overlap</td></tr>\n<tr><td><code>Commands</code></td><td>A queue of structural changes</td><td>Applied after the system ends, before later systems that depend on it</td></tr>\n<tr><td><code>Local&lt;T&gt;</code></td><td>State private to this system</td><td>Persists between runs; no sharing and no conflicts</td></tr>\n<tr><td><code>MessageReader&lt;T&gt;</code>, <code>MessageWriter&lt;T&gt;</code></td><td>Buffered messages</td><td>Each reader tracks its own position</td></tr>\n<tr><td><code>ParamSet&lt;(A, B)&gt;</code></td><td>Several conflicting parameters, used one at a time</td><td>The escape hatch for error B0001</td></tr>\n</tbody>\n</table>\n<p>This program uses four of them, plus two features of the scheduler that real projects need immediately: system sets, which name a phase so ordering can be stated once for many systems, and run conditions, which skip a system based on a function. The three phases run in order (input, simulate, report) because <code>configure_sets</code> chains them; <code>earn</code> only runs while a <code>not_paused</code> condition holds; <code>Local</code> counts how many times <code>earn</code> has run without a resource; and <code>Single</code> expresses \"the player\" without a loop.</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use bevy_ecs::prelude::*;\n\n#[derive(Component)]\nstruct Player;\n\n#[derive(Component)]\nstruct Gold(u32);\n\n#[derive(Resource, Default)]\nstruct Paused(bool);\n\n#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]\nenum Phase {\n    Input,\n    Simulate,\n    Report,\n}\n\nfn earn(mut gold: Single&lt;&amp;mut Gold, With&lt;Player&gt;&gt;, mut calls: Local&lt;u32&gt;) {\n    *calls += 1;\n    gold.0 += 5 * *calls;\n}\n\nfn pause_on_three(mut paused: ResMut&lt;Paused&gt;, gold: Single&lt;&amp;Gold, With&lt;Player&gt;&gt;) {\n    if gold.0 &gt;= 30 {\n        paused.0 = true;\n    }\n}\n\nfn report(gold: Single&lt;&amp;Gold, With&lt;Player&gt;&gt;) {\n    println!(\"gold = {}\", gold.0);\n}\n\nfn not_paused(paused: Res&lt;Paused&gt;) -&gt; bool {\n    !paused.0\n}\n\nfn main() {\n    let mut world = World::new();\n    world.init_resource::&lt;Paused&gt;();\n    world.spawn((Player, Gold(0)));\n\n    let mut schedule = Schedule::default();\n    schedule.configure_sets((Phase::Input, Phase::Simulate, Phase::Report).chain());\n    schedule.add_systems(pause_on_three.in_set(Phase::Input));\n    schedule.add_systems(earn.in_set(Phase::Simulate).run_if(not_paused));\n    schedule.add_systems(report.in_set(Phase::Report));\n\n    for frame in 1..=6 {\n        print!(\"frame {frame}: \");\n        schedule.run(&amp;mut world);\n    }\n}\n</code></pre>\n<pre><code class=\"language-text\">frame 1: gold = 5\nframe 2: gold = 15\nframe 3: gold = 30\nframe 4: gold = 30\nframe 5: gold = 30\nframe 6: gold = 30</code></pre>\n<p>Gold grew by 5, then 10, then 15 on the first three frames (the local call counter makes each award larger), reached 30 on frame 3, and then the input phase set the pause flag at the start of frame 4. The run condition then skipped <code>earn</code> from frame 4 on, while the report still ran every frame. Notice what the sets bought: <code>pause_on_three</code> is declared in a different <code>add_systems</code> call from <code>earn</code>, and the order between them is guaranteed by the phases, not by the order of the calls. In a real game this is how input, physics, AI and rendering are stacked into frames, and it scales to hundreds of systems because each system names its phase and nothing else about the others. A stricter question than \"which order\" is \"which data\": the scheduler orders only by what you state, and overlaps everything else, so a missing ordering between two systems that share data on purpose is a bug that appears as flicker, not as a compile error. State the dependency (<code>before</code>, <code>after</code>, <code>in_set</code>, <code>chain</code>) the moment one system relies on another's results.</p>\n<h2>Testing systems</h2>\n<p>A system is a function over a world, so a test builds the world it needs and runs the schedule once. This is the strongest argument for the architecture: no engine, no window, no fixtures beyond a few spawned entities. A test for the reaper might spawn an enemy with health 0, run the frame schedule, and assert that <code>world.query::&lt;&amp;Enemy&gt;().iter(&amp;world).count() == 0</code> and that the score resource is 10. Because the scheduler is deterministic for systems that are chained, tests that need exact order chain them; tests of independent systems run each one alone, with <code>world.run_system_once(system)</code> (import <code>bevy_ecs::system::RunSystemOnce</code>) as a shorthand when you do not want to build a schedule; it returns a <code>Result</code>, and running <code>heal</code> on a world with one entity of health 5 left it at 15. Keep time out of the systems by passing a <code>Time</code>-like resource that tests control, the same injection idea as in <a href=\"/library/rust-dependency-injection\">dependency injection</a>.</p>\n<h2>Performance: what an ECS buys and what it does not</h2>\n<p>ECS is often sold as fast, and the honest answer depends on what you compare against. This benchmark updates one million entities' positions from their velocities, in a release build on an Apple M1 Max, best of seven runs, against two hand-written alternatives, then measures adding and removing a component on 100,000 entities:</p>\n<pre><code class=\"language-text\">1M entities, one movement update:\n  bevy_ecs query                1.07 ms (1.07 ns/entity)\n  bevy_ecs, no change tracking   1.10 ms (1.10 ns/entity)\n  Vec of 64-byte structs        1.13 ms (1.13 ns/entity)\n  two Vecs (struct of arrays)   0.25 ms (0.25 ns/entity)\nstructural change on 100k entities: insert 6.1 ms (61 ns each), remove 4.9 ms (49 ns each)</code></pre>\n<p>Read the numbers carefully. The query matched a plain <code>Vec</code> of 64-byte structs (1.07 ms, about one nanosecond per entity), which is the layout a straightforward game loop would use, and was about four times slower than a hand-written struct-of-arrays loop over two tight vectors (0.25 ms). Turning off change detection for the writes made no difference, so that bookkeeping was not the gap; I did not profile further, and the likely causes are the generality of a query (it must work for any component set and handles access checks and ticks) and a loop the compiler cannot vectorise. The lesson is not that ECS is slow: 1 ns per entity is about 900 updates per second with a million moving things on one core. The lesson is that ECS is a <em>flexibility</em> tool that is fast enough, not a replacement for a hand-tuned data layout in the one loop that dominates your frame. If a single hot loop is the whole program, write the arrays. If you have dozens of systems over many overlapping groups of entities, the ECS gives you that layout for all of them without writing it.</p>\n<p>The second result is the one that bites in practice. Adding a component to an entity moves it to a different <em>archetype</em> (a table holding all entities with exactly that set of components), which costs about 60 ns for an insert and 45 ns for a remove here: a copy of the entity's row. That is cheap for an occasional change and expensive if every entity toggles a flag every frame. For flags that flip often, Bevy offers sparse-set storage, declared on the component: <code>#[component(storage = \"SparseSet\")]</code>. Adds and removes become cheaper, and iteration over entities with that component becomes slower, so use it for transient markers like <code>Stunned</code> and keep ordinary data in tables.</p>\n<h2>The error you will meet first</h2>\n<p>A system that asks for conflicting access to the same data cannot run, and Bevy refuses at the moment it builds the schedule. Here a system holds a mutable query over all <code>Health</code> and a shared query over the players' health at once:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use bevy_ecs::prelude::*;\n\n#[derive(Component)]\nstruct Health(i32);\n\n#[derive(Component)]\nstruct Player;\n\nfn heal_others(mut all: Query&lt;&amp;mut Health&gt;, players: Query&lt;&amp;Health, With&lt;Player&gt;&gt;) {\n    let _ = players.iter().count();\n    for mut h in &amp;mut all {\n        h.0 += 1;\n    }\n}\n\nfn main() {\n    let mut world = World::new();\n    world.spawn((Player, Health(10)));\n    let mut schedule = Schedule::default();\n    schedule.add_systems(heal_others);\n    schedule.run(&amp;mut world);\n    println!(\"ran\");\n}\n</code></pre>\n<pre><code class=\"language-text\">thread 'main' panicked at bevy_ecs-0.20.1/src/query/state.rs:216:13:\nerror[B0001]: &lt;Enable the debug feature to see the name&gt; in system &lt;Enable the debug feature to see the name&gt; accesses component(s) &lt;Enable the debug feature to see the name&gt; in a way that conflicts with a previous system parameter. Consider using `Without&lt;T&gt;` to create disjoint Queries or merging conflicting Queries into a `ParamSet`. See: https://bevy.org/learn/errors/b0001\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace</code></pre>\n<p>This is error B0001. The message hides the names unless you enable the crate's <code>debug</code> feature; do that in development, because \"accesses component(s) Health in a way that conflicts\" is the whole diagnosis. Rust's borrow checker would reject the equivalent code; in an ECS the check happens when the system parameters are validated. There are two fixes. If the queries should select disjoint entities, say so with filters: <code>Query&lt;&amp;mut Health, Without&lt;Player&gt;&gt;</code> and <code>Query&lt;&amp;Health, With&lt;Player&gt;&gt;</code>. If you really need both views of the same data, wrap the queries in a <code>ParamSet</code> and use them one at a time.</p>\n<h2>Using it outside a game</h2>\n<p>The same structure suits any program with many entities and cross-cutting behaviours: a discrete-event simulation (agents with different traits), a build system or scheduler (tasks with dependencies and states as components), an editor (document nodes with optional properties), a network simulator. The tests are easy to write: build a world, spawn entities, run a schedule once, assert on queries. That is one of the strongest reasons to use <code>bevy_ecs</code> directly. Alternatives exist with different trade-offs: <code>hecs</code> is smaller and has no scheduler, <code>specs</code> and <code>shipyard</code> take different approaches to scheduling. The related theory, laying data out for the cache, is in <a href=\"/library/rust-data-oriented-design\">data-oriented design</a>, and the system-and-query machinery in full Bevy is in <a href=\"/library/bevy-systems-queries-resources\">systems, queries and resources</a>.</p>\n<h2>When not to use an ECS</h2>\n<p>Skip it when the program has a handful of distinct objects with fixed behaviour; a few structs and traits are simpler and faster to write. Skip it when the dominant cost is one numeric loop, as the benchmark shows. And be wary of it when your data is a graph or tree with strong parent-child ownership and few entities per type: an ECS can model hierarchy (Bevy has parent and child components), but it is no longer the natural fit. Use it where composition and many entities are the problem, and measure before assuming the layout makes the difference.</p>","date_published":"2026-11-03T09:36:00+00:00","tags":["Systems"]},{"id":"https://rusthacks.dev/library/rust-linux-daemon-systemd","url":"https://rusthacks.dev/library/rust-linux-daemon-systemd","title":"Writing a Linux Daemon in Rust With Systemd Notify","summary":"Under systemd a daemon does not fork or write pid files. It stays in the foreground, tells systemd when it is ready and exits cleanly on SIGTERM.","content_html":"<p>A Linux service that systemd starts, supervises and stops has to follow a small set of rules, and getting them right is the difference between a daemon that restarts cleanly at three in the morning and one that leaves a half-started process behind. Rust makes the program side easy and leaves the contract with systemd to you. This guide builds the contract piece by piece: how systemd learns that your service is ready (the notify protocol), how it learns that you are still alive (the watchdog), how you reload without dropping connections, how you shut down on SIGTERM, how to take a listening socket from systemd instead of binding one, and which unit-file options turn a normal process into a locked-down one. The notify protocol is a datagram sent to a Unix socket, so it can be tested without systemd; the programs below were run on macOS against a stand-in socket that records the same messages systemd would receive, and compiled with Rust 1.99, tokio 1.54, <code>sd-notify</code> 0.5 and <code>listenfd</code> 1. The unit files are the standard directives from <code>systemd.service(5)</code>; check them with <code>systemd-analyze verify</code> on your machine, since this guide could not run systemd itself.</p>\n<h2>What systemd needs to know about your process</h2>\n<p>The default service type, <code>Type=simple</code>, treats the service as started the moment the process is forked. That is almost never true: the process is still reading configuration, opening ports and warming caches. Anything ordered <code>After=</code> your service may start while you are not ready, and a deployment script that runs <code>systemctl start</code> returns before the port accepts connections. With <code>Type=notify</code>, systemd waits until the service sends <code>READY=1</code> over the notification socket. The same channel carries status text, the watchdog heartbeat and reload and stop announcements. The protocol is a short list of <code>KEY=VALUE</code> lines in one datagram sent to the path in the <code>NOTIFY_SOCKET</code> environment variable:</p>\n<table>\n<thead><tr><th>Message</th><th>Meaning</th><th>When to send</th></tr></thead>\n<tbody>\n<tr><td><code>READY=1</code></td><td>Startup is finished; the service is serving</td><td>After the port is bound and the state loaded</td></tr>\n<tr><td><code>STATUS=text</code></td><td>Free-form status shown by <code>systemctl status</code></td><td>Whenever the state changes in a way an operator cares about</td></tr>\n<tr><td><code>RELOADING=1</code> plus <code>MONOTONIC_USEC=n</code></td><td>A reload is in progress</td><td>Immediately when a reload begins; send <code>READY=1</code> when it ends</td></tr>\n<tr><td><code>STOPPING=1</code></td><td>Shutdown has begun</td><td>On SIGTERM, before draining</td></tr>\n<tr><td><code>WATCHDOG=1</code></td><td>Still alive</td><td>At least twice per <code>WatchdogSec</code></td></tr>\n<tr><td><code>ERRNO=n</code>, <code>EXTEND_TIMEOUT_USEC=n</code></td><td>Failure code; ask for more time</td><td>Slow starts or stops</td></tr>\n</tbody>\n</table>\n<h2>The daemon, with every signal handled</h2>\n<p>A daemon is a loop that waits for one of three things: a termination signal, a reload signal, or time for the next watchdog ping. In async Rust, that is a <code>select!</code> over signal streams and an interval. This is the whole daemon, with the \"work\" left out so the lifecycle is visible:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">async fn daemon() {\n    let mut term = signal(SignalKind::terminate()).unwrap();\n    let mut hup = signal(SignalKind::hangup()).unwrap();\n\n    tokio::time::sleep(Duration::from_millis(300)).await;\n    sd_notify::notify(&amp;[NotifyState::Ready, NotifyState::Status(\"accepting connections\")]).unwrap();\n\n    let mut watchdog = sd_notify::watchdog_enabled().map(|d| d / 2).map(tokio::time::interval);\n    loop {\n        tokio::select! {\n            _ = term.recv() =&gt; {\n                sd_notify::notify(&amp;[NotifyState::Stopping, NotifyState::Status(\"draining\")]).unwrap();\n                tokio::time::sleep(Duration::from_millis(100)).await;\n                return;\n            }\n            _ = hup.recv() =&gt; {\n                let usec = sd_notify::NotifyState::monotonic_usec_now().unwrap();\n                sd_notify::notify(&amp;[NotifyState::Reloading, usec]).unwrap();\n                tokio::time::sleep(Duration::from_millis(50)).await;\n                sd_notify::notify(&amp;[NotifyState::Ready, NotifyState::Status(\"reloaded\")]).unwrap();\n            }\n            _ = async { match watchdog.as_mut() { Some(i) =&gt; { i.tick().await; } None =&gt; std::future::pending().await } } =&gt; {\n                sd_notify::notify(&amp;[NotifyState::Watchdog]).unwrap();\n            }\n        }\n    }\n}\n\n</code></pre>\n<p>Walk through what it does. It registers handlers for SIGTERM and SIGHUP first, so a signal arriving during startup is queued rather than killing the default way. The 300 ms sleep stands in for startup work. <code>READY=1</code> and a status line are sent together in one datagram, which is what the crate's slice argument does. The watchdog interval is half the period systemd asked for, which is the documented rule of thumb: <code>sd_notify::watchdog_enabled()</code> returns <code>Some(duration)</code> only if <code>WATCHDOG_USEC</code> is set (and, with systemd, if <code>WATCHDOG_PID</code> names this process), so the daemon works unchanged outside systemd, where it simply does not ping. On SIGHUP it announces <code>RELOADING=1</code> with the current monotonic time, which systemd requires for the notify-reload type, does the reload, and announces <code>READY=1</code> again. On SIGTERM it announces <code>STOPPING=1</code>, drains, and returns, so the process exits with status 0.</p>\n<h2>Seeing the conversation without systemd</h2>\n<p>To check that these messages are what systemd would see, a test harness plays systemd's part: it binds a datagram socket, starts the daemon as a child with <code>NOTIFY_SOCKET</code> pointing at it and a 400 ms watchdog, sends SIGHUP at 900 ms and SIGTERM at 1,500 ms, and logs everything it receives:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">fn harness() {\n    let path = std::env::temp_dir().join(format!(\"notify-{}.sock\", std::process::id()));\n    let _ = std::fs::remove_file(&amp;path);\n    let sock = UnixDatagram::bind(&amp;path).unwrap();\n    sock.set_read_timeout(Some(Duration::from_millis(200))).unwrap();\n\n    let exe = std::env::current_exe().unwrap();\n    let mut child = Command::new(exe)\n        .arg(\"daemon\")\n        .env(\"NOTIFY_SOCKET\", &amp;path)\n        .env(\"WATCHDOG_USEC\", \"400000\")\n        .env(\"WATCHDOG_PID\", \"\")\n        .stdout(Stdio::null())\n        .spawn()\n        .unwrap();\n    let pid = child.id() as i32;\n    let t0 = Instant::now();\n\n    let mut log: Vec&lt;(u128, String)&gt; = Vec::new();\n    let mut buf = [0u8; 512];\n    let mut sent_hup = false;\n    let mut sent_term = false;\n    loop {\n        match sock.recv(&amp;mut buf) {\n            Ok(n) =&gt; log.push((t0.elapsed().as_millis() / 100 * 100, String::from_utf8_lossy(&amp;buf[..n]).replace('\\n', \" | \"))),\n            Err(_) =&gt; {}\n        }\n        let ms = t0.elapsed().as_millis();\n        if ms &gt; 900 &amp;&amp; !sent_hup {\n            unsafe { libc::kill(pid, libc::SIGHUP) };\n            sent_hup = true;\n        }\n        if ms &gt; 1500 &amp;&amp; !sent_term {\n            unsafe { libc::kill(pid, libc::SIGTERM) };\n            sent_term = true;\n        }\n        if let Some(status) = child.try_wait().unwrap() {\n            log.push((t0.elapsed().as_millis() / 100 * 100, format!(\"process exited: {status}\")));\n            break;\n        }\n    }\n    let _ = std::fs::remove_file(&amp;path);\n    let mut last = String::new();\n    for (ms, line) in log {\n        if line.starts_with(\"WATCHDOG=1\") &amp;&amp; last == line {\n            continue;\n        }\n        println!(\"{ms:&gt;5} ms  {line}\");\n        last = line;\n    }\n}\n\nfn main() {\n    if std::env::args().nth(1).as_deref() == Some(\"daemon\") {\n        tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(daemon());\n    } else {\n        harness();\n    }\n}\n</code></pre>\n<pre><code class=\"language-text\">300 ms  READY=1 | STATUS=accepting connections | \n  300 ms  WATCHDOG=1 | \n  900 ms  RELOADING=1 | MONOTONIC_USEC=776077409717 | \n  900 ms  READY=1 | STATUS=reloaded | \n 1100 ms  WATCHDOG=1 | \n 1500 ms  STOPPING=1 | STATUS=draining | \n 1700 ms  process exited: exit status: 0</code></pre>\n<p>The timeline is the lifecycle. At 300 ms the service reports ready with a status, then pings the watchdog (the repeated pings every 200 ms are collapsed in the log). At 900 ms the SIGHUP produced the two-message reload sequence, and the monotonic timestamp is a microsecond count that differs on each run. At 1,500 ms SIGTERM produced <code>STOPPING=1</code>, and 200 ms later the process exited with status 0. A harness like this is worth keeping as an integration test: it asserts the protocol without root, without a VM and without waiting for a real systemd. The one thing it cannot check is systemd's reaction, for instance that a missed watchdog really kills and restarts the service; that belongs in a staging test with <code>systemd-run</code>.</p>\n<h2>The unit file</h2>\n<p>The unit file turns those behaviours on and adds the supervision policy. This one is a production-shaped starting point; the comments say what each line is for:</p>\n<pre data-filename=\"/etc/systemd/system/myapp.service\"><code class=\"language-text\">[Unit]\nDescription=My Rust service\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=notify\nNotifyAccess=main\nExecStart=/usr/local/bin/myapp --config /etc/myapp/config.toml\nExecReload=/bin/kill -HUP $MAINPID\n\n# Supervision\nRestart=on-failure\nRestartSec=2\nWatchdogSec=10\nTimeoutStartSec=30\nTimeoutStopSec=30\n\n# Identity and state\nDynamicUser=yes\nStateDirectory=myapp\nConfigurationDirectory=myapp\nLogsDirectory=myapp\n\n# Hardening\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\nPrivateDevices=yes\nProtectKernelTunables=yes\nProtectControlGroups=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX\nCapabilityBoundingSet=\nMemoryMax=512M\n\n[Install]\nWantedBy=multi-user.target</code></pre>\n<p>Read it in three groups. <strong>Supervision.</strong> <code>Type=notify</code> with <code>NotifyAccess=main</code> means only the main process may send notifications, which stops a stray child from claiming readiness. <code>Restart=on-failure</code> restarts after a non-zero exit, a signal death, or a watchdog timeout, but not after a clean stop. <code>WatchdogSec=10</code> promises systemd a ping at least every 10 seconds (the daemon sends one every five); if none arrives, systemd kills the service and the restart policy applies, which turns a deadlocked process into a restarted one. The two timeouts bound startup and shutdown, after which systemd sends SIGKILL. <strong>Identity.</strong> <code>DynamicUser=yes</code> allocates a transient unprivileged user for the service, and the three <code>*Directory=</code> options create directories under <code>/var/lib</code>, <code>/etc</code> and <code>/var/log</code> that the user owns, so the service never needs root and never needs a pre-created account. <strong>Hardening.</strong> <code>NoNewPrivileges</code> blocks privilege escalation via setuid binaries; <code>ProtectSystem=strict</code> makes the whole file system read-only except the directories you declare; <code>PrivateTmp</code> gives the service its own <code>/tmp</code>; <code>CapabilityBoundingSet=</code> with nothing after it drops every Linux capability; <code>RestrictAddressFamilies</code> forbids exotic socket types; <code>MemoryMax</code> turns a leak into an out-of-memory kill of this service only, instead of the host. Check how exposed a unit is with <code>systemd-analyze security myapp.service</code>, which scores each directive. Binding a port below 1024 under these rules needs either socket activation (next section) or <code>AmbientCapabilities=CAP_NET_BIND_SERVICE</code>.</p>\n<h2>Socket activation: take the socket instead of binding it</h2>\n<p>Letting systemd create the listening socket and pass it to your process solves three problems at once. The service can run unprivileged yet listen on port 80, because systemd (root) bound it. Connections that arrive while the service is restarting queue in the kernel's accept backlog instead of being refused, so a restart or deploy drops nothing. And the service can start on demand. The unit pair is a socket unit and a service unit with the same name:</p>\n<pre data-filename=\"/etc/systemd/system/myapp.socket\"><code class=\"language-text\">[Socket]\nListenStream=0.0.0.0:8080\nAccept=no\n\n[Install]\nWantedBy=sockets.target</code></pre>\n<p>On start, systemd passes the socket as file descriptor 3 and sets <code>LISTEN_FDS=1</code> and <code>LISTEN_PID</code> to your pid. The <code>listenfd</code> crate reads those and hands you a listener, with a fallback to binding yourself when the process was not started by systemd, so the same binary works in development:</p>\n<pre data-filename=\"src/main.rs\"><code class=\"language-rust\">use listenfd::ListenFd;\nuse std::net::TcpListener;\n\nfn listener() -&gt; std::io::Result&lt;TcpListener&gt; {\n    let mut fds = ListenFd::from_env();\n    match fds.take_tcp_listener(0)? {\n        Some(l) =&gt; {\n            println!(\"using the socket systemd passed in (fd 3)\");\n            Ok(l)\n        }\n        None =&gt; {\n            println!(\"no inherited socket, binding 127.0.0.1:0 myself\");\n            TcpListener::bind(\"127.0.0.1:0\")\n        }\n    }\n}\n\nfn main() -&gt; std::io::Result&lt;()&gt; {\n    let l = listener()?;\n    println!(\"listening on {}\", l.local_addr()?.ip());\n    Ok(())\n}\n</code></pre>\n<p>Run outside systemd, it takes the fallback path, as the output shows when it ran here:</p>\n<pre><code class=\"language-text\">no inherited socket, binding 127.0.0.1:0 myself\nlistening on 127.0.0.1</code></pre>\n<p>Under systemd the first branch runs and prints the message about the inherited socket, with the address configured in the socket unit. Convert the listener to Tokio with <code>tokio::net::TcpListener::from_std</code> after calling <code>set_nonblocking(true)</code>. Because the socket outlives the process, a <code>systemctl restart myapp</code> never closes the port.</p>\n<h2>Reload that cannot take the service down</h2>\n<p>The daemon announces a reload, but the interesting part is what the reload does. Three rules make it safe: parse and validate the new configuration completely before touching the live one; replace the live configuration atomically, so no request ever sees half of the old and half of the new; and keep the old configuration if the new one is bad, reporting the error instead of exiting. A small shared structure delivers all three. Readers take an <code>Arc</code> snapshot and keep using it for the whole request, and the reload swaps the pointer under a write lock held for a few nanoseconds:</p>\n<pre data-filename=\"src/config.rs\"><code class=\"language-rust\">use std::sync::{Arc, RwLock};\n\n#[derive(Debug, Clone, PartialEq)]\nstruct Config {\n    workers: usize,\n    greeting: String,\n}\n\nfn parse(text: &amp;str) -&gt; Result&lt;Config, String&gt; {\n    let mut workers = None;\n    let mut greeting = None;\n    for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {\n        let (key, value) = line.split_once('=').ok_or_else(|| format!(\"line {}: expected key=value\", n + 1))?;\n        match key.trim() {\n            \"workers\" =&gt; {\n                let w: usize = value.trim().parse().map_err(|e| format!(\"line {}: workers: {e}\", n + 1))?;\n                if !(1..=64).contains(&amp;w) {\n                    return Err(format!(\"line {}: workers must be 1..=64, got {w}\", n + 1));\n                }\n                workers = Some(w);\n            }\n            \"greeting\" =&gt; greeting = Some(value.trim().to_string()),\n            other =&gt; return Err(format!(\"line {}: unknown key {other:?}\", n + 1)),\n        }\n    }\n    Ok(Config { workers: workers.ok_or(\"missing workers\")?, greeting: greeting.ok_or(\"missing greeting\")? })\n}\n\nstruct Shared {\n    current: RwLock&lt;Arc&lt;Config&gt;&gt;,\n}\n\nimpl Shared {\n    fn snapshot(&amp;self) -&gt; Arc&lt;Config&gt; {\n        self.current.read().unwrap().clone()\n    }\n\n    fn reload(&amp;self, text: &amp;str) -&gt; Result&lt;(), String&gt; {\n        let new = parse(text)?;\n        *self.current.write().unwrap() = Arc::new(new);\n        Ok(())\n    }\n}\n\nfn main() {\n    let shared = Shared { current: RwLock::new(Arc::new(parse(\"workers = 4\\ngreeting = hello\").unwrap())) };\n\n    let in_flight = shared.snapshot();\n    println!(\"start:  {:?}\", shared.snapshot());\n\n    println!(\"reload ok:     {:?}\", shared.reload(\"workers = 8\\ngreeting = hello again\"));\n    println!(\"now serving:   {:?}\", shared.snapshot());\n    println!(\"old request still sees: {:?}\", in_flight);\n\n    println!(\"reload broken: {:?}\", shared.reload(\"workers = 800\\ngreeting = x\"));\n    println!(\"reload typo:   {:?}\", shared.reload(\"worker = 2\"));\n    println!(\"still serving: {:?}\", shared.snapshot());\n}\n</code></pre>\n<pre><code class=\"language-text\">start:  Config { workers: 4, greeting: \"hello\" }\nreload ok:     Ok(())\nnow serving:   Config { workers: 8, greeting: \"hello again\" }\nold request still sees: Config { workers: 4, greeting: \"hello\" }\nreload broken: Err(\"line 1: workers must be 1..=64, got 800\")\nreload typo:   Err(\"line 1: unknown key \\\"worker\\\"\")\nstill serving: Config { workers: 8, greeting: \"hello again\" }</code></pre>\n<p>Read the output as a sequence of promises. The snapshot taken before the reload (<code>in_flight</code>) still sees four workers after the swap, so a request that started under the old configuration finishes under it; no request ever observes an inconsistent mix. The valid reload took effect for new snapshots. The two bad reloads returned errors that name the line and the problem, and the service kept serving the previous configuration. In the daemon, the SIGHUP branch reads the file, calls <code>reload</code>, and on <code>Err</code> logs the message and sends <code>STATUS=reload failed: ...</code> followed by <code>READY=1</code>, so the operator sees the failure in <code>systemctl status</code> while production traffic continues. The lock is a plain <code>RwLock</code> because the critical section is a pointer copy; for a hot path with millions of reads per second, the <code>arc-swap</code> crate makes reads lock-free, a trade measured in <a href=\"/library/tokio-share-state\">sharing state between tasks</a>.</p>\n<p>What a reload cannot change is anything bound to a resource: the listening address, a TLS key already loaded into a handshake context, the number of worker threads in a started runtime. For those, the honest options are to document that they need a restart, or to implement the change by starting new resources, switching traffic and draining the old ones, which is a mini deployment inside the process. Keep the set of reloadable settings small and write it down in the unit's documentation; an operator who sends SIGHUP and discovers that the port did not change has been misled by the signal's name. A related system-level detail: systemd does not run <code>ExecReload</code> when the main process is not active, and treats a non-zero exit from the reload command as a failed reload, which is why the unit above uses <code>kill -HUP</code> (it exits 0 once the signal is delivered) and relies on the notify protocol for the outcome.</p>\n<h2>Knowing you are running under systemd</h2>\n<p>A binary that runs both on laptops and under systemd should adapt without flags. Systemd sets a few environment variables you can read. <code>INVOCATION_ID</code> exists for every service started by systemd and is unique per run, so it doubles as a correlation id in logs. <code>JOURNAL_STREAM</code> is set when stdout or stderr is connected to the journal, and its presence is the signal to drop timestamps and colour from your log format. <code>NOTIFY_SOCKET</code> and <code>WATCHDOG_USEC</code>, as above, enable the lifecycle messages, and the <code>sd-notify</code> crate already treats their absence as \"do nothing\", so the same code path is safe everywhere. <code>LISTEN_FDS</code> enables socket activation, handled by <code>listenfd</code>. Checking these is cheaper and more accurate than checking whether the parent process is pid 1.</p>\n<p>Environment is also the right place for the deployment's secrets and paths, with <code>EnvironmentFile=/etc/myapp/env</code> in the unit (mode 0600, owned by root), or better <code>LoadCredential=</code>, which hands the service a file in a private directory named by <code>$CREDENTIALS_DIRECTORY</code> that is never visible to other users or to <code>systemctl show</code>. Reading a credential is a plain file read, so the Rust side stays simple, and it keeps keys out of the process environment where <code>/proc/PID/environ</code> and crash reporters can see them.</p>\n<h2>Logging: write to stdout, let the journal do the rest</h2>\n<p>A service under systemd should not manage log files or rotate them. Write to standard output and standard error; the journal captures both, attaches the unit name, user and timestamps, and rotates by size. Read with <code>journalctl -u myapp -f</code>. Two refinements are worth making. Do not print timestamps yourself, since the journal adds them (configure your <code>tracing</code> subscriber with <code>.without_time()</code> when it detects <code>JOURNAL_STREAM</code> in the environment). And preserve severity: the journal understands a syslog-style prefix, so a line starting with <code>&lt;3&gt;</code> is stored as an error and <code>&lt;6&gt;</code> as info (the <code>SyslogLevelPrefix</code> default is on). The <code>tracing-journald</code> crate sends structured fields directly instead, which makes <code>journalctl -o json</code> output queryable by field. Keep one rule for secrets: nothing the service prints may contain them, because the journal is world-readable to members of the <code>systemd-journal</code> group; see <a href=\"/library/rust-logging-without-leaking-secrets\">logging without leaking secrets</a>.</p>\n<h2>Shutting down properly</h2>\n<p>When you run <code>systemctl stop</code>, systemd sends SIGTERM to the main process, waits up to <code>TimeoutStopSec</code>, and then sends SIGKILL to everything left. Your job in that window is the drain: stop accepting, finish in-flight work, flush, exit with status 0. The pattern that does it with cancellation tokens and a deadline is in <a href=\"/library/tokio-graceful-shutdown\">graceful shutdown</a>, and the daemon above is its skeleton. Two details specific to systemd: the stop is announced with <code>STOPPING=1</code> so that tools showing the state see \"deactivating\" with your status text, and exit status 0 after SIGTERM is what makes <code>Restart=on-failure</code> leave the service stopped. A process that exits non-zero because it caught the signal and returned an error would be restarted by the supervisor, which is rarely what someone typing <code>systemctl stop</code> wanted.</p>\n<h2>What goes wrong</h2>\n<table>\n<thead><tr><th>Symptom</th><th>Cause</th><th>Fix</th></tr></thead>\n<tbody>\n<tr><td><code>systemctl start</code> hangs for 90 seconds then fails with \"timeout\"</td><td><code>Type=notify</code> but the process never sent <code>READY=1</code></td><td>Send it after startup; check <code>NOTIFY_SOCKET</code> is set and <code>NotifyAccess</code> allows your pid</td></tr>\n<tr><td>Service restarts every <code>WatchdogSec</code></td><td>Pings stopped: a blocked runtime, or the ping interval is longer than half the period</td><td>Ping from a task that cannot be starved; send every half period; see <a href=\"/library/blocking-the-tokio-runtime\">blocking the runtime</a></td></tr>\n<tr><td>\"Failed to set up mount namespacing\" or permission denied under <code>ProtectSystem</code></td><td>The service writes outside its declared directories</td><td>Use <code>StateDirectory</code>, <code>RuntimeDirectory</code> or <code>ReadWritePaths=</code></td></tr>\n<tr><td>Port below 1024 fails to bind</td><td>No capability, no socket activation</td><td>Use a <code>.socket</code> unit or <code>AmbientCapabilities=CAP_NET_BIND_SERVICE</code></td></tr>\n<tr><td>Reload kills connections</td><td>The reload restarts instead of re-reading</td><td>Handle SIGHUP in place; keep the listener and swap the config behind a lock or <code>ArcSwap</code></td></tr>\n<tr><td>Logs show twice or with double timestamps</td><td>The app adds its own timestamp and prefix</td><td>Turn them off when running under the journal</td></tr>\n<tr><td>Service stays \"activating\" forever after SIGHUP</td><td><code>RELOADING=1</code> sent without <code>READY=1</code> afterwards</td><td>Always pair them</td></tr>\n</tbody>\n</table>\n<h2>Checking the unit before you deploy</h2>\n<pre><code class=\"language-bash\">systemd-analyze verify /etc/systemd/system/myapp.service\nsystemd-analyze security myapp.service\nsystemd-run --user --collect --wait --property=Type=notify ./target/release/myapp\njournalctl -u myapp --since \"5 minutes ago\"</code></pre>\n<p>The first catches typos and unknown directives, the second scores the sandbox and lists what is still exposed, and the third runs your binary under a transient unit with the same contract, a quick way to try the notify handshake on a laptop that runs systemd. In CI, run the harness from this guide to cover the part that is pure Rust.</p>\n<h2>Where this does not apply</h2>\n<p>Inside a container, the container runtime is the supervisor: Docker and Kubernetes restart containers, send SIGTERM and read exit codes, but they ignore <code>NOTIFY_SOCKET</code> unless you run systemd inside the container, and their health model is probes, not the watchdog. The shutdown and signal handling carry over directly (see <a href=\"/library/rust-graceful-shutdown-kubernetes\">graceful shutdown on Kubernetes</a>), the unit file does not. On macOS, <code>launchd</code> has its own plist format and no equivalent of the notify protocol, and on Windows a service uses the Service Control Manager API. If you target several, put the lifecycle behind a small trait (<code>ready()</code>, <code>status()</code>, <code>stopping()</code>) with a no-op implementation for platforms without it. For the systemd socket and watchdog features in more depth, see <a href=\"/library/rust-systemd-sockets-watchdog\">systemd sockets and watchdog</a>.</p>","date_published":"2026-11-03T09:34:00+00:00","tags":["Systems"]}]}