tokio-rs

Activity

Stars and mentions · Last 30 days, Oct 9 – Nov 7

GitHub stars521
Mentions27
011223344Oct 9Oct 23Nov 7
UTC days · repositories tokio-rs owns5 repositories

Top repositories

tokio-rs’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside tokio-rs’s account where tokio-rs opened or reviewed pull requests in the past 365 days.

Who noticed tokio-rs

Everyone who starred or forked tokio-rs’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

28posts
Nov 81 post
  1. Lock-Free Ring Buffers in Rust for Multi-Tenant Webhook Ingress: Avoiding Mutex Contention at High Request Rates Webhook ingestion engines are bursty by nature. A single tenant can send a flood of events while thousands of others send a trickle, and the ingress path has to hand each payload from a network-facing thread to a processing thread without stalling either side. At very high request rates (this article uses 1,000,000 requests per second as its reference point), the queue between those two stages becomes one of the hottest shared data structures in the process. This article explains why a lock-protected queue can become a bottleneck under heavy contention, what bounded lock-free ring buffers are and how they work, and how to build one in Rust. It also covers the production alternatives (crossbeam-queue and crossbeam-channel) and how to keep one noisy tenant from starving the others. It does not publish benchmark numbers, because queue performance depends heavily on hardware, payload size, contention pattern and kernel version. A closing section explains how to measure it yourself. --- The Bottleneck: What Actually Goes Wrong with Mutexes First, a correction to a common back-of-the-envelope calculation. 1M RPS does not mean “1 microsecond per request” for your application as a whole. It means one request arrives every microsecond on average. With N cores working in parallel, each core has roughly N microseconds of CPU time per request. The real constraint is the shared queue: every request passes through it, so any per-operation cost on that queue is multiplied by the full request rate, and any serialization limits total throughput no matter how many cores you add. A mutex-protected queue serializes all producers and consumers on one lock. Under heavy contention, three things tend to go wrong. 1. Parking and context switches Since Rust 1.62, the standard library’s std::sync::Mutex on Linux is built on a futex word rather than a pthread mutex. The implementation spins briefly while the lock is held, then falls back to a futex_wait system call that puts the thread to sleep. An uncontended lock or unlock is just an atomic operation, so mutexes are cheap when contention is low. The trouble starts when many threads compete for the same lock: waiters end up in the kernel, and the unlocking thread has to issue a wake-up call. Published microbenchmarks put the direct cost of a Linux thread context switch at roughly 1–4 microseconds. One measurement on x86 found about 1.2–1.5 µs when pinned to a single core and about 2.2 µs unpinned. Another reported futex-based hand-offs at around 1.3–1.9 µs. The figure varies with the CPU, kernel and measurement method, and it excludes the indirect cost of reloading caches after the switch. Even at the low end, a futex sleep and wake-up is orders of magnitude more expensive than the tens of nanoseconds a cache-resident atomic operation typically costs. > A note on tokio::sync::Mutex: this mutex does not park OS threads. Its lock().await yields the task while the lock is held elsewhere. The Tokio documentation explains that it exists mainly so a guard can be held across .await points, and that this makes it more expensive than a blocking mutex, so the blocking mutex should be preferred where it can be used. The thread-parking behavior discussed above applies to std::sync::Mutex (and equivalents such as parking_lot), not to the async mutex. 2. Cache-line ping-pong When several cores repeatedly write to the same cache line, such as a lock word or a queue index, the line has to move between cores. Each write invalidates other cores’ copies, and each subsequent access by another core is slower as a result. This coherence traffic is a major cost of contended shared state. It is also why the layout of the queue’s metadata matters, which the next section addresses. 3. Head-of-line blocking across tenants If all tenants share one queue and one lock, a spike from one tenant slows everyone else. The same is true if the queue has a single capacity limit: a flood from Tenant A can fill it and cause Tenant B’s webhooks to be rejected. Lock-free structures reduce the first problem but do nothing for the second, which is a queue-topology problem addressed in the multi-tenant section below. Worker 1 ---> [ holds lock ] ---> [ lock line owned by Core 0 ] Worker 2 ---> [ spin, then futex_wait ] ---> [ sleeps in kernel ] Worker 3 ---> [ spin, then futex_wait ] ---> [ sleeps in kernel ] | v Throughput bounded by the one thread holding the lock --- Queue Shapes: SPSC, SPMC and MPMC Choose the queue by how many threads touch each end. | Architecture | Producers | Consumers | Complexity | Contention | Typical use | | --- | --- | --- | --- | --- | --- | | SPSC | 1 | 1 | Low | None between ends | Dedicated core-to-core pipeline | | SPMC | 1 | Many | Medium | Consumers compete for the head | One I/O thread feeding a worker pool | | MPSC | Many | 1 | Medium | Producers compete for the tail | Many handlers feeding one writer or aggregator | | MPMC | Many | Many | Higher | Both ends contended | Shared ingress pool with many I/O threads and many workers | A common ingress design is one network I/O thread per core (optionally pinned with OS thread affinity), each pushing into its own queue, with worker threads draining those queues. Since each queue has exactly one producer, the producer side needs no compare-and-swap loop. Only the consumer side does, if more than one worker shares a queue. Pinning is an optimization to measure, not a requirement. --- How a Bounded Lock-Free Ring Buffer Works A bounded ring buffer is a fixed array of slots plus two monotonically increasing positions: where the next item will be enqueued and where the next item will be dequeued. A position maps to a slot with pos & (capacity - 1) when capacity is a power of two, which is cheaper than a modulo. The design used below follows Dmitry Vyukov’s bounded MPMC queue, which is also the basis of crossbeam-queue’s ArrayQueue and the array flavor of crossbeam-channel. Each slot carries a sequence number (a “stamp”) that tells threads whether the slot is ready for a producer or for a consumer: - Initially, slot i has sequence i. - A producer at position pos may write when slot.sequence == pos. After writing, it stores pos + 1 with Release ordering. - A consumer at position pos may read when slot.sequence == pos + 1. After reading, it stores pos + capacity with Release ordering, marking the slot free for the next lap. Threads claim positions by advancing the shared enqueue or dequeue counter with compare_exchange, and publish data through the slot’s sequence number. No thread ever holds a lock, and a thread that loses a race simply retries with an updated position. Cache-line padding The enqueue and dequeue counters are written by different threads, so they must not share a cache line, or every enqueue would invalidate the line the consumers are polling. crossbeam_utils::CachePadded aligns and pads a value to avoid this. The padding size is not always 64 bytes. According to the crossbeam-utils documentation, CachePadded assumes 128-byte cache lines on x86-64, aarch64 and powerpc64. On modern Intel CPUs the spatial prefetcher pulls pairs of 64-byte lines, so crossbeam pessimistically aligns to 128 bytes. It uses 32 bytes on arm, mips, mips64, sparc and hexagon, 256 on s390x, 16 on m68k, and 64 elsewhere. The crate itself notes that this is a reasonable guess and not a guarantee about the machine the program runs on. --- Memory Orderings, Briefly Every atomic operation in Rust takes an Ordering argument. The ones used here: - Relaxed guarantees atomicity of the operation itself but no ordering relative to other memory accesses. It is suitable for counters and for reading a position you will later validate. - Release (on a store) makes all of the thread’s earlier writes visible to any thread that later performs an Acquire load of the same atomic and observes the stored value. In the queue, the producer writes the payload first, then stores the sequence number with Release. - Acquire (on a load) pairs with that Release. A consumer that observes the new sequence number is guaranteed to see the payload written before it. - AcqRel applies both semantics to a read-modify-write operation such as compare_exchange. In this queue, the Acquire/Release pair on each slot’s sequence number carries all of the data synchronization. The compare_exchange calls on the shared counters only arbitrate which thread owns a position, so Relaxed is sufficient for them. That is also how the reference algorithm and crossbeam’s implementations use them. --- A Bounded MPMC Ring Buffer in Rust The implementation below is a straightforward port of Vyukov’s algorithm. It is a teaching example and a starting point, not a substitute for a battle-tested crate. If you use it, run it under Miri and loom, and stress-test it. The sequence-number logic, the use of MaybeUninit and the unsafe blocks are exactly where subtle bugs hide. use std::cell::UnsafeCell; use std::mem::MaybeUninit; use std::sync::atomic::{AtomicUsize, Ordering}; use crossbeam_utils::CachePadded; struct Slot<T> { sequence: AtomicUsize, value: UnsafeCell<MaybeUninit<T>>, } /// Bounded multi-producer multi-consumer queue (Vyukov-style). pub struct RingBuffer<T> { buffer: Box<[Slot<T>]>, mask: usize, enqueue_pos: CachePadded<AtomicUsize>, // advanced by producers dequeue_pos: CachePadded<AtomicUsize>, // advanced by consumers } // Slots are handed between threads only through the sequence protocol. unsafe impl<T: Send> Sync for RingBuffer<T> {} impl<T> RingBuffer<T> { /// `capacity` must be a power of two and at least 2. pub fn new(capacity: usize) -> Self { assert!( capacity >= 2 && capacity.is_power_of_two(), "capacity must be a power of two >= 2" ); let buffer = (0..capacity) .map(|i| Slot { sequence: AtomicUsize::new(i), value: UnsafeCell::new(MaybeUninit::uninit()), }) .collect::<Vec<_>>() .into_boxed_slice(); Self { buffer, mask: capacity - 1, enqueue_pos: CachePadded::new(AtomicUsize::new(0)), dequeue_pos: CachePadded::new(AtomicUsize::new(0)), } } /// Try to enqueue. Returns the value back if the buffer is full. pub fn push(&self, value: T) -> Result<(), T> { let mut pos = self.enqueue_pos.load(Ordering::Relaxed); loop { let slot = &self.buffer[pos & self.mask]; let seq = slot.sequence.load(Ordering::Acquire); let diff = seq.wrapping_sub(pos) as isize; if diff == 0 { // Slot is free for this position: try to claim it. match self.enqueue_pos.compare_exchange_weak( pos, pos.wrapping_add(1), Ordering::Relaxed, Ordering::Relaxed, ) { Ok(_) => { unsafe { (*slot.value.get()).write(value) }; // Publish the payload to consumers. slot.sequence.store(pos.wrapping_add(1), Ordering::Release); return Ok(()); } Err(actual) => pos = actual, // lost the race; retry } } else if diff < 0 { // Slot still holds an unconsumed item from the previous lap. return Err(value); } else { // Another producer already claimed this position; reload. pos = self.enqueue_pos.load(Ordering::Relaxed); } } } /// Try to dequeue. Returns `None` if the buffer is empty. pub fn pop(&self) -> Option<T> { let mut pos = self.dequeue_pos.load(Ordering::Relaxed); loop { let slot = &self.buffer[pos & self.mask]; let seq = slot.sequence.load(Ordering::Acquire); let diff = seq.wrapping_sub(pos.wrapping_add(1)) as isize; if diff == 0 { match self.dequeue_pos.compare_exchange_weak( pos, pos.wrapping_add(1), Ordering::Relaxed, Ordering::Relaxed, ) { Ok(_) => { let value = unsafe { (*slot.value.get()).assume_init_read() }; // Mark the slot free for the next lap. slot.sequence .store(pos.wrapping_add(self.mask + 1), Ordering::Release); return Some(value); } Err(actual) => pos = actual, } } else if diff < 0 { // Producer has not published this slot yet: empty. return None; } else { pos = self.dequeue_pos.load(Ordering::Relaxed); } } } } impl<T> Drop for RingBuffer<T> { fn drop(&mut self) { // Drop any items still in the buffer. while self.pop().is_some() {} } } The single-producer variant If exactly one thread ever calls push, the producer-side compare_exchange is unnecessary, because no other thread competes for the enqueue position. That is the SPMC case. Because the compiler cannot enforce “only one producer”, the method is unsafe and the caller takes on that contract: impl<T> RingBuffer<T> { /// # Safety /// At most one thread may call this method (or `push`) at any time. pub unsafe fn push_single_producer(&self, value: T) -> Result<(), T> { let pos = self.enqueue_pos.load(Ordering::Relaxed); let slot = &self.buffer[pos & self.mask]; // The slot is free only when its sequence equals the position. if slot.sequence.load(Ordering::Acquire) != pos { return Err(value); // full } (*slot.value.get()).write(value); slot.sequence.store(pos.wrapping_add(1), Ordering::Release); // Only this thread writes enqueue_pos, so a plain store suffices. self.enqueue_pos.store(pos.wrapping_add(1), Ordering::Relaxed); Ok(()) } } Consumers still use the CAS-based pop, so multiple workers can safely share the queue. If you instead want one consumer per queue, you can build the mirror-image single-consumer pop. --- Production Options: crossbeam-queue and crossbeam-channel For most services, a maintained crate is a better choice than custom unsafe code. crossbeam_queue::ArrayQueue ArrayQueue is a bounded multi-producer multi-consumer queue that allocates a fixed-capacity buffer on construction. Pushing into a full queue fails and returns the element, and force_push lets it act as an overwriting ring buffer. Its implementation is based on Vyukov’s bounded MPMC queue, with CachePadded head and tail counters, the same design described above. For raw “push a payload, pop a payload” semantics with no blocking, it is the closest off-the-shelf equivalent of the code in this article. use crossbeam_queue::ArrayQueue; let q = ArrayQueue::new(1024); assert!(q.push(42u64).is_ok()); assert_eq!(q.pop(), Some(42)); crossbeam-channel crossbeam-channel is a full MPMC channel library. Its bounded channels use an array flavor that is, again, based on Vyukov’s bounded MPMC queue and preallocates its buffer. Unlike a bare queue, it supports blocking send/recv and select!. Blocking operations can put threads to sleep, so on a latency-critical ingress path use the non-blocking try_send and try_recv. Two points of terminology to keep straight: crossbeam-channel is MPMC (it has no dedicated SPMC type), and “lock-free” describes its fast path. The blocking paths still involve waker bookkeeping and thread parking when a channel is empty or full. Reference ingress architecture HTTP clients I/O threads (one per core) Bounded queues Workers +-----------------+ +----------------------------+ +----------------+ +-----------+ | Tenant A reqs | ---> | I/O thread 0 (parse, auth) | -> | Queue 0 (SPMC) | -> | Worker 0 | | Tenant B reqs | ---> | I/O thread 1 (parse, auth) | -> | Queue 1 (SPMC) | -> | Worker 1 | +-----------------+ +----------------------------+ +----------------+ +-----------+ try_push, never blocks pop (CAS) Non-blocking dispatch with crossbeam-channel use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; #[derive(Debug)] pub struct WebhookPayload { pub tenant_id: String, pub body: Vec<u8>, pub received_at_epoch_ms: u64, } #[derive(Debug)] pub enum IngressError { QueueFull, Offline, } pub struct WebhookIngress { tx: Sender<WebhookPayload>, } impl WebhookIngress { pub fn new(capacity: usize) -> (Self, Receiver<WebhookPayload>) { let (tx, rx) = bounded(capacity); (Self { tx }, rx) } /// Never blocks: a full queue becomes backpressure (e.g. HTTP 429/503). pub fn handle_incoming(&self, payload: WebhookPayload) -> Result<(), IngressError> { self.tx.try_send(payload).map_err(|e| match e { TrySendError::Full(_) => IngressError::QueueFull, TrySendError::Disconnected(_) => IngressError::Offline, }) } } A note on the Vec<u8> and String fields: each payload owns heap allocations made when the request was parsed. The queue itself does not allocate per message, but the messages do. If allocation shows up in profiles, consider pooled buffers or bytes::Bytes. What about tokio::sync::mpsc? If your producers and consumers are both Tokio tasks, tokio::sync::mpsc is usually the right default. It stores elements in blocks organized as a linked list, reuses emptied blocks where it can, and gives you async backpressure for bounded channels. It is a multi-producer, single-consumer channel, so it does not fit a shared worker pool without a dispatcher task. It is designed for async-to-async communication, not for the thread-to-thread hand-off this article focuses on. A reasonable decision rule: - Async tasks on both ends: tokio::sync::mpsc. - OS threads on both ends, latency-critical: ArrayQueue or a bounded crossbeam-channel with try_send. - Async producer, blocking consumer pool: bridge deliberately and measure, since the Tokio documentation itself discusses mixing sync and async channel endpoints. --- Multi-Tenant Isolation A fast queue does not fix noisy neighbors. If Tenant A fills a shared queue, Tenant B’s webhooks are rejected no matter how efficient the queue is. Fairness has to be designed in at the queue-topology and admission-control level. 1. Separate queues per tier or per shard Give each service tier its own bounded queue and its own workers, so a free-tier flood cannot consume enterprise capacity. pub struct TieredRouter { enterprise: crossbeam_channel::Sender<WebhookPayload>, standard: crossbeam_channel::Sender<WebhookPayload>, } impl TieredRouter { pub fn route(&self, tier: &str, payload: WebhookPayload) -> Result<(), IngressError> { let tx = match tier { "enterprise" => &self.enterprise, _ => &self.standard, }; tx.try_send(payload).map_err(|e| match e { TrySendError::Full(_) => IngressError::QueueFull, TrySendError::Disconnected(_) => IngressError::Offline, }) } } For very large tenant counts, per-tenant queues become unwieldy. Hash tenants onto a fixed number of shards instead, and give the largest tenants dedicated shards. 2. Rate limit before the queue Shed load at the edge, before a payload consumes queue capacity. A token bucket can be implemented with atomics, for example by packing a token count and a timestamp into a single AtomicU64 and updating it with a compare-exchange loop. Per-tenant limits keep a spike from reaching the shared buffers at all. Return a clear status code (such as 429 Too Many Requests) so well-behaved senders back off and retry. 3. Reserve capacity for high-priority traffic Pair tiered queues with worker assignment: dedicate some workers exclusively to the enterprise queue, and let the remaining workers drain the standard queue. Work stealing between tiers is possible but reintroduces the cross-tenant contention you are trying to avoid. --- Measure Before You Commit Lock-free is not automatically faster. A lock-free queue under heavy contention still suffers cache-line transfers, and CAS retry loops can burn CPU. Whether a mutex-protected VecDeque, crossbeam-channel, ArrayQueue or a custom queue wins depends on payload size, the number of producers and consumers, core topology (including NUMA), and how bursty the traffic is. Some practical guidance: - Benchmark with realistic producer/consumer counts and payload sizes, using a harness such as Criterion.rs. Report tail latencies (p99, p99.9), not just averages. - Run the benchmark on the hardware you deploy on. Cache-line sizes, core counts and interconnects differ between x86 and ARM servers. - Use perf (for example perf stat and perf c2c) to look for cache-line contention and context-switch counts in the real process. - Test correctness separately: use Miri for undefined behavior and loom for exhaustive interleaving checks of any custom lock-free code. - Compare against the simplest baseline first. An uncontended or lightly contended Mutex is cheap, and a design with sharded queues and few shared structures may beat any single lock-free queue. --- Conclusion and Checklist At very high request rates, a single contended mutex can limit how many webhooks you can ingest, mostly because of futex parking, wake-ups and cache-line transfers. A bounded lock-free ring buffer based on per-slot sequence numbers avoids the locking and keeps the hot path to a few atomic operations, but it only works well if you also handle layout, backpressure and tenant fairness. 1. Pad shared cursors. Wrap the enqueue and dequeue counters in crossbeam_utils::CachePadded, and do not hard-code 64 bytes: crossbeam assumes 128 on x86-64 and aarch64. 2. Use power-of-two capacities so that index wrapping is a bitwise AND, not a modulo. 3. Prefer proven implementations. crossbeam_queue::ArrayQueue or a bounded crossbeam-channel covers most needs. Write custom queues only when profiling justifies it, and test them with Miri and loom. 4. Never block at ingress. Use try_send or push, and turn a full queue into explicit backpressure (HTTP 429 or 503) rather than parking I/O threads. 5. Isolate tenants structurally with tiered or sharded queues and rate limiting before the queue. 6. Match the tool to the runtime. Use tokio::sync::mpsc between async tasks, and lock-free thread queues between OS threads. 7. Measure tail latency on your own hardware before and after every change. --- Originally published at instawebhook.com

  2. Learn more about Topcoat (Rust's web app framework) here: tokio-rs/topcoat. And yes, Topcoat still needs an actual website.

    2 likes · 154 views
  3. 【Rust】Topcoatとは?【Webフレームワーク】 Topcoat は、Rust で Web アプリを作るためのフレームワークです。Next.js でやっている「画面もサーバーも一つのプロジェクトで書く」を、Rust 側に持ってきたもの、と考えると近いです。 画面の HTML はサーバーが作ります。ボタンの開閉のような小さな動きは、書いた Rust の式を JavaScript に変換して、ブラウザの中で動かします。データベースが要る部分だけ、サーバーにもう一度描き直してもらいます。WebAssembly という、ブラウザで Rust そのものを動かす方式は使いません。 作っているのは、Rust で非同期処理の土台になっている Tokio のチームです。2026-07-22 に公開されました。記事の時点(2026-11-05)の最新は 0.11.0(2026-10-24)です。 結論 Next.js に慣れている人なら、画面の書き方は想像しやすいです。コンポーネントは関数です。その関数の中でデータを取れます。ファイルの置き場所が URL になります。ただし、今は安定版ではありません。試すにはよい段階で、本番の長期保守を預ける段階ではありません。 メリット API を別に作らなくてよい。 画面の関数がサーバーで動くので、Next.js の Server Component のように、その場でデータベースを読めます。 ブラウザに大きな変換結果を配らない。 開閉やカウントのように、手元のデータだけで決まる動きは、JavaScript に変換した式がその場で処理します。サーバーへの往復が要りません。 データが要るところだけサーバーに聞き直せる。 検索結果のように、サーバーの情報が要る部分は、その部分だけ描き直します。ページ全体の再読み込みは要りません。 今の Web アプリで欲しいものが最初から入っている。 ファイルの位置で決まるルート、Tailwind CSS、shadcn/ui のように自分のプロジェクトへコピーする UI 部品、開発サーバー、ORM の Toasty です。 見た目が JSX に近い。 HTML の中に値を埋める書き方なので、React を書いたことがあれば、Rust の文法を全部覚える前でも流れは追えます。 デメリット 1.0 ではない。 公式は “Early-stage and experimental. Expect breaking changes.” と書いています。実験段階で、書き方が変わる前提です。発表直後の紹介では「アルファ版、未完成」と言われていました。0.11.0 まで番号は進みましたが、安定版という意味にはなっていません。 保存してから画面に出るまでが遅い。 Rust はコンパイルが重いです。発表直後に触った人は、文字を 1 文字変えただけで 1 秒以上待った、と話していました。Next.js と Vite の、一瞬で反映される感覚とは違います。規模が大きくなるほど待ちやすいです。 ブラウザで動かせる Rust は一部だけ。 変換できる式の範囲を超える動き、オフライン、ブラウザの中で重い計算を回すアプリには向きません。 情報が少ない。 公開から数か月です。詰まったときに見つかる記事や、本番で長く動いている例は、Next.js よりずっと少ないです。 Rust 自体の学習は残る。 画面のコードは簡単に見えます。エラーの読み方、パッケージの入れ方、所有権でつまずく箇所は、フレームワークが消してくれるわけではありません。 Next.js との対応 用語を先に並べます。Right 側は、厳密に同じものではなく「覚えるときの足がかり」です。 Next.js で知っているもの Topcoat での近いもの app/about/page.tsx が /about になる src/app/about.rs が /about になる Server Component(サーバーで描画し、その中でデータを取る) #[component] や #[page] を付けた async 関数 JSX view! { ... } の中の HTML useState と、ブラウザだけで完結する表示の切り替え signal と $(...) 一部だけサーバーに聞き直して差し替える #[shard] React.cache で、同じリクエスト中の二重取得をまとめる #[memoize] shadcn/ui をプロジェクトにコピーして直す Topcoat UI を topcoat ui でコピーして直す Route Handler だけが欲しいとき 画面ごと任せるなら Topcoat。HTTP の API だけなら、同じチームの Axum Axum は、Next.js でいうとフレームワーク全体ではなく、ルートの受け口に近いライブラリです。Topcoat は画面、部品、見た目の更新まで含めます。API だけを出すなら Axum、画面のあるアプリなら Topcoat、という使い分けです。両方を一つのプロダクトで使うことも想定されています。 画面はどう書くか 最小のページは、次の形です。見慣れない記号だけ先に訳します。 async fn は、待つ処理を書ける関数です。Server Component の async function に近いです。 Result は、成功か失敗かを返す型です。例外を投げる代わりに、これを返します。 view! の中が HTML です。文字列は "..."、Rust の値は (name) のように丸括弧で埋めます。 #[page] async fn home() -> Result<impl View> { Ok(view! { <!DOCTYPE html> <html> <body> hello(name: "World") </body> </html> }) } #[component] async fn hello(name: &str) -> Result<impl View> { Ok(view! { <h1>"Hello, " (name) "!"</h1> }) } hello(name: "World") は、下の関数を部品として呼んでいます。React で <Hello name="World" /> と書く位置です。引数に名前が付くので、どれがどの値かは読みやすいです。 部品の中でデータベースを読んでも、その処理はサーバーに残ります。ブラウザに SQL や秘密が渡る、という形にはなりません。渡るのは出来上がった HTML と、ブラウザで動かすと決めた式だけです。 ブラウザで動く部分と、サーバーに戻る部分 ここが Topcoat の中心です。二つのやり方があります。 手元で足りる動きは、signal と $(...) です。signal は useState に近い、変わる値です。$(...) の中は Rust として型を検査され、同じ式が JavaScript になってブラウザで再実行されます。 let open = signal(cx, || false); view! { <button @click=$(|_e| open.set(!open.get()))>"開く"</button> <p :hidden=$(!open.get())>"Topcoat の説明"</p> } ボタンを押すと open が反転し、段落の表示が切り替わります。この往復はサーバーへ行きません。@click はクリック、:hidden はその値に表示を結びつける、という意味です。 サーバーの情報が要る動きは #[shard] です。検索語が変わるたびに、その部品だけをサーバーで描き直し、返ってきた HTML をその場所へ差し替えます。Next.js で、入力のたびに Route Handler を叩いて結果の HTML を受け取る、に近いです。違うのは、型の付いた引数として書けることです。 使い分けは単純です。ブラウザの中のデータだけで決まるなら $(...)。データベースや権限が要るなら #[shard]。公式は、このブラウザ側の仕組みはまだ発展途中で、足りない操作があると書いています。足りないときは htmx や Alpine.js と組み合わせる道も残しています。考え方は、ページの一部だけを差し替える Hotwire や htmx に近いです。 同じデータを一つのリクエストで何度も取りそうなときは、#[memoize] を付けます。React の cache と同じで、同じ引数ならそのリクエストの中では 1 回にまとめます。 ログインが要る画面は、middleware の奥に隠すより、部品の中で「ログインしていなければログインへ戻す」関数を呼ぶ書き方が勧められています。見ているコードの近くに条件があるので、後から読んでも抜けを見つけやすい、という方針です。 最初から入っているもの 2026-11-05 時点で、次はフレームワーク側にあります。 ルート。 src/app.rs が /、src/app/about.rs が /about です。関数名は URL になりません。フォルダとファイルの位置が URL です。アンダースコアで始まるモジュールは、URL に出ないレイアウトに使えます。 開発サーバー。 topcoat new でプロジェクトを作り、topcoat dev で起動します。保存すると再ビルドされ、準備ができるとブラウザが更新されます。 見た目。 Tailwind CSS は Node.js を別に入れなくても使えます。ボタンやカードは、ライブラリとして黒箱で依存するのではなく、自分のソースへコピーしてから直します。shadcn/ui と同じ考え方です。画像やフォントは、中身のハッシュを付けた URL で配ります。 データ。 推奨構成では ORM の Toasty と SQLite が入ります。モデルを変えたあとはマイグレーションを生成して適用します。メール、Cookie、セッション(保存先は自分で用意する形)も扱えます。 あとから差し込まれる更新。 live! と emit! で、読み込み中の表示を先に出し、終わったら中身を差し替えられます。ページを開いたあとも、WebSocket でサーバーから表示を更新できます。チャットのように、サーバー側の変化を画面へ流したいときに使います。 始め方は、Rust と Cargo(npm に近い道具)を入れたあと、次です。 cargo install topcoat-cli topcoat new hello-world --recommended cd hello-world topcoat dev --recommended は、ルート、Tailwind、UI、Toasty、ToDo の例まで入るセットです。データベース無しの小さい確認なら --minimal です。 向き不向き 向いているのは、一覧、フォーム、管理画面のように、サーバーが持っているデータを HTML で見せるアプリです。ところどころ開閉や絞り込みがある、という形です。Rust で API も画面も同じ言語に揃えたいチームにも合います。 向いていないのは、ブラウザの中でアプリの大半が動くもの、オフライン、変換できない細かい操作が画面の中心になるものです。その場合は、ブラウザでも Rust を動かす Leptos や Dioxus の方が目的に合います。HTTP の API だけなら Axum のままの方が小さいです。 まとめ Topcoat は、Next.js で慣れている「サーバーで HTML を作り、必要なところだけブラウザを動かす」を、Rust でやるフレームワークです。部品は関数、ルートはファイルの位置、小さな動きは JavaScript への変換、データが要る動きは部分的な再描画です。UI と ORM と開発サーバーが最初から入ります。 バージョンは、2026-11-05 時点で 0.11.0 です。発表直後はアルファ版と呼ばれる未完成の状態でした。番号は 0.11 まで進み、セッションやサーバーからの表示更新も入りました。それでも 1.0 ではなく、公式は実験段階であり、互換性のない変更があると明記しています。アルファという表示が crates.io に付いているわけではありません。安定版として採用できる状態でもありません。 触るなら、本番の置き換えではなく、小さい管理画面や ToDo で書き味を見るのがよいです。待ち時間は Rust のコンパイルです。画面の書き方は、React の関数コンポーネントから入れます。書き方が変わる前提で、公式の Getting Started をそのときの版で見てください。 参照 tokio-rs/topcoat crates.io の topcoat(2026-11-05 時点の最新は 0.11.0) 公開時の記事(2026-07-22) その後の機能の記事(2026-09-24) 紹介していた回(4:27 付近から Topcoat)

  4. Phone to PC without the cloud: a local HTTPS server, a pinned certificate and mDNS You take a photo on your phone and need it on your laptop, which is right there on the desk. On a Mac with an iPhone, AirDrop does it in two taps. On Windows, or with an Android phone, the photo goes up to someone's cloud, through a chat app or into an email to yourself, and back down again: two metres, by way of a data centre. The phone and the computer are on the same Wi-Fi. They should be able to talk to each other directly, with no account and no server in between. That's the idea behind Dropple. It runs on Mac and Windows, and works with any phone. This post is about how it works, and about the parts that were harder than I expected. The shape of it The desktop app is built with Tauri 2, with a Rust backend. Inside it runs an axum server on the local network: plain HTTP on one port for first contact, and HTTPS (rustls) on another. On the phone there are two ways in: - Any phone's browser. You scan a QR code and the computer serves the whole phone side as a web page. Nothing gets installed, and it works on an iPhone too. - A native Android app, which talks to the same API and stays connected in the background. Files stream both ways. Text sent from the phone goes straight onto the computer's clipboard. Live updates go out to every connected phone as Server-Sent Events, which turned out much simpler than WebSockets for a list that only changes on one side at a time. phone ──HTTPS──▶ axum (Rust, inside the Tauri app) ▲ │ └──── SSE /api/events ┘ "the list changed, fetch it" Trust without a certificate authority HTTPS on a home network is awkward. There's no domain name, so no public certificate. Dropple makes its own certificate authority on first run (with rcgen) and uses it to issue a certificate for the computer's current IP address. When the address changes, it issues a new one. The authority is name-constrained to private addresses and .local, so even if it were trusted somewhere, it couldn't vouch for a real website. A browser would need that authority installed before it trusts the connection, which is a real hurdle on a phone. The Android app gets around it by pinning instead: 1. The QR code carries the SHA-256 fingerprint of the computer's authority. 2. The app's trust manager accepts a connection only if the server's chain contains a certificate with exactly that fingerprint, and the server's own certificate was signed by it. Nothing goes into the phone's trust store, there's nothing for the user to install, and a lookalike server on the same Wi-Fi gets nowhere. I've written up [the trust manager itself](#) separately. Finding the computer again Laptops change IP addresses. They join a different network, the router hands out a new lease, or the laptop wakes up from sleep. The computer announces itself over mDNS as _dropple._tcp, with the start of its fingerprint in a TXT record. When the saved address stops answering, the Android app searches with Android's NSD and reconnects to the computer whose fingerprint matches. Connecting still checks the full fingerprint, so the TXT record only narrows the search; it never grants trust. One Samsung phone turned this into a bug hunt. Its NSD results contained only an IPv6 link-local address (fe80::…%wlan0), which you can't put into an https:// URL. The app sat on "Connecting…" forever. Details in [the NSD post](#). Staying connected on Android, politely Receiving files in the background means a foreground service of type connectedDevice, with an ongoing notification. Two things made that bearable: - Explaining before asking. Before Android shows its notification prompt, a short sheet says what the notifications are for. More people say yes, and nobody is surprised. - An off switch for every annoying part. Users can hide the "Connected" notification's channel, or turn the background connection off entirely. Android also won't let an app in the background read the clipboard, so "send what I just copied" needs a trick. A Quick Settings tile opens an invisible activity, which waits until it has window focus, reads the clipboard, sends it and closes. Write-up [here](#). What I'd tell someone building something similar - Pin, don't install. If you control both ends, pin the certificate rather than asking users to trust a new authority. - Test discovery on more than one brand of phone. NSD results differ between them. - Make reconnecting visible. A spinner with no words looks like a hang; "Looking for your computer…" doesn't. Users forgive waiting when they can see what's being waited for. - Keep the last state. If the computer is out of reach, show the last list you had rather than an empty screen. Sharing in Dropple is free, on Mac, Windows, any phone's browser, and Android through Google Play: scannertechs.com Happy to answer questions about any of this in the comments.

    1 reaction
  5. One year of Dioxus fullstack in production: the good, the painful After a year of Dioxus fullstack in production, the Rust code is the most boring part of my business. CI is the most exciting part, and not in a good way: a release build takes 90 minutes on a GitHub-hosted runner. That year started at my previous job and continued on my own products. Back in April I wrote about why I picked Rust for a production micro SaaS. The short version: slower at the start, cheaper to live with later. This post is the bill. What I'm running SeggWat is an in-app feedback tool for SaaS products. Its dashboard is a Dioxus 0.7 fullstack app inside a Cargo workspace: a backend crate with server functions, an auth crate, and about ten UI crates covering insights, feedback, ideas, boards, widgets and settings. Twelve crates depend on dioxus. Everything sits on PostgreSQL. infra.page is built on the same stack. "Fullstack" means one Rust codebase doing three jobs. It renders on the server, runs server functions as the backend, and compiles the client to WebAssembly so the page becomes interactive. The good One language, all the way down Database query, business rule, server function, button: all Rust. One toolchain, one formatter, one set of habits, one place to look when something breaks. That sounds minor until you're a solo founder juggling several products. I don't have a "frontend brain" and a "backend brain" that each need warming up. I open the workspace and keep going. The same types on both sides This is the one I'd miss most. A struct gets defined once. The database layer returns it, the server function sends it, and the component renders it. When I rename a field, cargo check gives me a list of every place that broke, including the UI. There's no OpenAPI spec to regenerate and no TypeScript interface drifting out of sync with the backend. The win isn't writing code faster. It's refactoring without fear, late at night, with nobody to review it. The door to other platforms stays open Dioxus can target desktop and mobile, including iOS, from the same component model. None of my products needs a native app today. But knowing I could ship one without a rewrite mattered when I decided where to put a year of work. Production is boring That's the whole point. Once it compiles, it mostly just runs. A whole class of "undefined is not a function" bugs never reaches users, so the bugs that do get through are logic bugs. That's where I'd rather spend my attention anyway. The "boring in production" argument from April survived contact with real users. The painful Compile times This is the part Rust evangelists tend to skip. The dashboard compiles twice: once for the server and once to wasm32 for the browser. A dozen Dioxus crates plus a macro-heavy UI add up. Locally it's tolerable on a decent machine. CI is a different story, and the comments in my workflow files read like a diary of it: - The WASM build alone takes about 22 minutes on 6 cores. That's a single leaf crate: the dashboard compiled for the browser. - A release image takes about 90 minutes on a GitHub-hosted 2-core runner. On my own VPS runner it's around 40, and that's the good version. - Clippy got OOM-killed. cargo clippy --workspace --all-targets on the 7 GB hosted runner died with exit code 143 and no diagnostics at all. The fix was a 10 GB swapfile plus CARGO_BUILD_JOBS=1 to cut peak memory. My linter needs swap. At some point I stopped building images on every push to main. They now build on release tags or when I trigger them by hand, because 90 billed minutes per push is a silly way to spend money. What I tried - Caching with Swatinem/rust-cache. It helps, but there's a trap. A workflow run can only restore caches from its own ref or the default branch, and GitHub evicts caches unused for 7 days. Once I stopped building on main, release builds kept starting cold. My workaround is almost funny: after a dependency bump, I manually trigger a build on main just to keep the cache warm. - Building and testing in one go, so the tests reuse compiled artifacts instead of compiling the world a second time. - The mold linker. Nice, but linking was never the bottleneck. Compiling was. - A self-hosted runner for the heavy builds. This was by far the biggest win, 90 minutes down to 40. It also means I now maintain a build server. Every one of these helped. None of them changed the underlying math: two compile targets, a big dependency tree and lots of macros means a lot of compiling. "All Rust" comes with an asterisk The business logic is all Rust. The edges aren't. SeggWat's insights page shows charts, and I couldn't find a Rust charting library I'd trust in production. So the charts are Chart.js, loaded from a CDN and wrapped in about 340 lines of JavaScript that Dioxus calls through dioxus-use-js. Styling is Tailwind, so CI also installs Bun and runs the Tailwind CLI before Rust even starts. I'm fine with that. It's pragmatic. But if you're choosing Dioxus specifically to escape JavaScript, know what you're signing up for: Rust, plus some JS glue, plus a JS toolchain in your pipeline. Would I do it again? People ask, "What helps me ship fastest?" The better question is, "What helps me keep shipping without hating my life?" For the backend, the answer is still Rust, and it isn't close. For the frontend, I've changed my mind a bit. If I were building something truly app-like, with heavy client state, offline support or a real mobile app, I'd pick Dioxus again. But most of what I build is dashboards: forms, tables, a few charts and some toggles. For that, I'm paying a 22-minute WASM tax on every release for interactivity I barely use. What's next: Topcoat So my next project is going to use Topcoat, a new fullstack framework from the tokio-rs org, announced in July. It makes the opposite bet from Dioxus. Everything renders on the server, and components are async functions that can query the database directly. For interactivity, you write $(...) expressions in ordinary, type-checked Rust. Topcoat evaluates them on the server for the first render and also translates them to JavaScript, so they re-run instantly in the browser. There's no WASM bundle and no second compile target. #[component] async fn faq(cx: &Cx) -> Result<impl View> { let open = signal(cx, || false); Ok(view! { <button @click=$(|_e| open.set(!open.get()))>"What is Topcoat?"</button> <p :hidden=$(!open.get())>"A full-stack Rust framework."</p> }) } Example from the Topcoat README. On paper, Topcoat keeps what I love about Dioxus (one language, shared types, no API layer) and removes the part that hurts most, the 22-minute WASM build. To be clear, this is a bet, not a result. Topcoat's README calls it early-stage and experimental, with breaking changes expected. I'm giving up the multi-platform option. And I haven't measured a single build yet. I'll write the follow-up with real numbers: compile times, CI minutes, and whatever I end up missing from Dioxus. If my release build drops from 40 minutes to 10, you'll hear about it. If it doesn't, you'll hear about that too.

  6. Topcoat 0.11 is out! The Rust web framework now ships with localizations (Fluent translations, number and date formatting), a topcoat new project generator, popovers, and more. Try it now! github.com/tokio-rs/topcoat/releases/tag/v0.11.0 t.co

    12 likes · 4 reposts · 207 views
  7. Reddit · · r/meshtastic · u/nakurtag · tokio-rs/tracing

    Termtastic — new TUI client for Linux, macOS and Windows preview.redd.it Hello, guys! I wanna introduce you Termtastic — my handmade feature-rich Meshtastic® console (TUI) client written in Rust. preview.redd.it Channels screen Chat Nodes tab Traceroute results Multiline message support Emojis Logs tab Settings tab Repo link: acelot/termtastic I wanted to create a cross-platform, minimalist, and aesthetically pleasing console client. I’ve been working on this application for six months. The entire project was built by hand, without the use of AI (aside from looking up reference information). This process gave me a deep understanding of how Meshtastic works under the hood; I’ve thoroughly explored its protocol and behavioral nuances. As the project evolved, I contributed to the official Meshtastic library for Rust, adding new features and making minor fixes. The long-term goal of the project is to achieve feature parity with the official Android app. The project is currently in the alpha-testing stage, so there may be bugs — some of which could even cause the program to crash. That’s why I need your help: please give my application a try — I’m confident many of you will enjoy it! Any feedback is welcome via Issues in Github. Highlights - Vibecode-free fully handwritten code - Dependency-free single-binary application - Cross-platform: Linux, macOS, Windows - Fully asynchronous (Tokio channels-based) - Optimized for small screens (down to 80x24 characters) - Respects the user terminal colors (no fancy opinionated TrueColor schemes) - Keyboard-centric UI navigation - Minimum use of emoji/non-ascii characters in UI - Small memory footprint (~20KB) - Supports multiple connection protocols (TCP, BLE, Serial) - Could discover devices automatically: BLE paired devices, TCP devices via mDNS, Serial devices - Uses standard system directories for storing configuration, database and logs Tech stack |Feature|Library| |:-|:-| |TUI: Framework|ratatui| |TUI: Backend|crossterm| |TUI: Inputs|ratatui-textarea| |TUI: Lists|tui-widget-list| |Interaction with Meshtastic|meshtastic| |Clipboard functionality|arboard| |Bluetooth devices discovery|btleplug| |TCP devices discovery|mdns-sd| |Logging|tracing| |Async/Channels|tokio| |Configuration|confy, etcetera| |Errors|anyhow, thiserror| |Datetime|chrono| |Emoji selector|emojis| |Local DB|rusqlite, rusqlite_migration| Download Latest releases are available here: github.com/acelot/termtastic/releases

    39 points · 15 comments
  8. Reddit · · r/meshtastic · u/nakurtag · tokio-rs/tracing

    Lemme introduce to you my TUI client — Termtastic Hello, guys! I wanna introduce you Termtastic — my handmade feature-rich Meshtastic® console (TUI) client written in Rust. preview.redd.it Repo link: acelot/termtastic I wanted to create a cross-platform, minimalist, and aesthetically pleasing console client. I’ve been working on this application for six months. The entire project was built by hand, without the use of AI (aside from looking up reference information). This process gave me a deep understanding of how Meshtastic works under the hood; I’ve thoroughly explored its protocol and behavioral nuances. As the project evolved, I contributed to the official Meshtastic library for Rust, adding new features and making minor fixes. The long-term goal of the project is to achieve feature parity with the official Android app. The project is currently in the alpha-testing stage, so there may be bugs — some of which could even cause the program to crash. That’s why I need your help: please give my application a try — I’m confident many of you will enjoy it! Any feedback is welcome via Issues in Github. Highlights - Vibecode-free fully handwritten code - Dependency-free single-binary application - Cross-platform: Linux, macOS, Windows - Fully asynchronous (Tokio channels-based) - Optimized for small screens (down to 80x24 characters) - Respects the user terminal colors (no fancy opinionated TrueColor schemes) - Keyboard-centric UI navigation - Minimum use of emoji/non-ascii characters in UI - Small memory footprint (~20KB) - Supports multiple connection protocols (TCP, BLE, Serial) - Could discover devices automatically: BLE paired devices, TCP devices via mDNS, Serial devices - Uses standard system directories for storing configuration, database and logs Tech stack |Feature|Library| |:-|:-| |TUI: Framework|ratatui| |TUI: Backend|crossterm| |TUI: Inputs|ratatui-textarea| |TUI: Lists|tui-widget-list| |Interaction with Meshtastic|meshtastic| |Clipboard functionality|arboard| |Bluetooth devices discovery|btleplug| |TCP devices discovery|mdns-sd| |Logging|tracing| |Async/Channels|tokio| |Configuration|confy, etcetera| |Errors|anyhow, thiserror| |Datetime|chrono| |Emoji selector|emojis| |Local DB|rusqlite, rusqlite_migration| Download Latest releases are available here: github.com/acelot/termtastic/releases

    8 points · 10 comments
  9. Reddit · · r/u_No-Philosophy3919 · u/No-Philosophy3919 · tokio-rs/tokio

    An elapsed timer can still be in use: the Tokio 1.54.1 fix If your application resolved Tokio 1.54.0 and uses time intervals, check the lockfile and move to 1.54.1. The October 21 release reverts a timer ownership change that upstream identified as a data race and use-after-free. The interesting part is the order of operations. A timer can be marked deregistered while the driver still has work to do with the entry's memory. In the failure described in PR #8692, StateCell::fire marked an entry as deregistered before taking its waker. Meanwhile, another thread could poll an interval. After observing the tick as ready, Interval::poll_tick called Sleep::reset_without_timer, which could drop or overwrite that entry while the driver was still accessing it. Tokio 1.54.0 had removed Drop implementations from TimerEntry and the alternative timer. Sleep still had cancellation on drop, but discarding its internal timer during reset did not go through that outer destructor. The lifetime guarantee therefore missed a destruction path. Version 1.54.1 restores cancellation on drop for the timer entries and the runtime handle needed by the traditional timer. Its cancellation path takes the driver lock and clears the entry. The lock provides the synchronization needed before the entry's storage can be reused. That gives a useful review question for intrusive queues and runtime internals: after a completion flag becomes visible, what proves that the producer has made its final access to the object? Removing the object from a logical data structure does not, by itself, answer that question. The destructor needs a synchronization boundary that also covers replacement and reset paths. The patch adds a model test, interval_poll_tick_races_with_fire. One thread polls an interval twice while the timer driver advances time on another thread. This targets the firing/reset interleaving directly. I inspected the upstream patch; I have not independently run the model test or reproduced the race. For an application, the immediate check is small: cargo tree -i tokio If the resolved version is 1.54.0, update the dependency resolution to 1.54.1, inspect the lockfile diff, and run the application's tests. A manifest range alone does not show which version the deployed binary contains. The official changelog dates 1.54.0 to October 19, 2026 and 1.54.1 to October 21. The latter lists this revert as its sole fix. These sources establish the regression in 1.54.0; they do not establish that every interval workload triggers it, or justify assigning a severity or CVE. Sources: tokio#8692 github.com/tokio-rs/tokio/blob/master/tokio/CHANG… github.com/tokio-rs/tokio/releases/tag/tokio-1.54… Disclosure: AI-assisted writing and source review. The race description and test are from upstream; no independent reproduction is claimed.

    1 point
  10. @heyandras You could do the funniest thing and go full Rust tokio-rs/topcoat

    1 like · 129 views

Achievements

Awards earned by the repositories tokio-rs owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.