How Sniffnet Performs Asynchronous IP Address Resolution Without Blocking the UI
Sniffnet isolates reverse‑DNS lookups in a dedicated worker thread and communicates results through Rust channels, allowing the UI thread to remain responsive while resolving IP addresses.
Sniffnet is a Rust-based network traffic analyzer that must resolve IP addresses to human-readable hostnames without freezing its graphical interface. The application achieves this by offloading potentially slow DNS queries to background threads and coordinating results through Rust's channel-based concurrency. This architecture ensures that the main packet processing loop and the UI event loop never block on network I/O.
The Challenge: Synchronous DNS Blocks the Event Loop
Reverse-DNS lookups using standard library functions like dns_lookup::lookup_addr perform synchronous network I/O that can take hundreds of milliseconds or longer. When performed on the main thread, these calls freeze the user interface, creating jank or unresponsive behavior during live traffic capture.
Thread Architecture and Channel Communication
Sniffnet solves this by separating concerns across multiple threads and using Rust's multi-producer, single-consumer (mpsc) channels for safe message passing.
The Capture Thread (parse_packets)
The main packet processing logic lives in src/networking/parse_packets.rs inside the parse_packets function. This runs in its own OS thread and handles the continuous stream of captured network packets.
The Reverse DNS Worker Thread
Inside parse_packets, Sniffnet spawns a second dedicated thread named thread_reverse_dns_lookups using std::thread::Builder. This thread exists solely to perform blocking DNS queries.
thread::Builder::new()
.name("thread_reverse_dns_lookups".to_string())
.spawn(move || {
reverse_dns_lookups(&lookup_request_rx, &lookup_result_tx, &mmdb_readers);
});
Channel-Based Coordination
The system uses two distinct channels to coordinate work between the parser and the DNS worker:
lookup_request_tx / lookup_request_rx: Carries lookup requests from the parser to the worker. Each message contains a tuple of(AddressPortPair, TrafficDirection, Vec<Address>).lookup_result_tx / lookup_result_rx: Returns resolved host information from the worker back to the parser. Messages are of typeHostMessage.
Requesting a DNS Lookup
When parse_packets encounters an IP address requiring resolution, it sends a request to the worker thread without blocking:
if !r_dns_already_resolved && !r_dns_waiting_resolution {
// Track that we are waiting for this address
resolutions_state.addresses_waiting_resolution.insert(
address_to_lookup,
DataInfo::new_with_first_packet(exchanged_bytes, traffic_direction),
);
// Send request to DNS worker thread
let _ = resolutions_state.lookup_request_tx.send((
key, // address/port pair from the packet
traffic_direction, // inbound or outbound
cs.get_addresses().clone(), // local interface addresses
));
}
The DNS Worker Implementation
The worker thread runs an infinite loop that blocks only inside lookup_addr, then immediately sends results back:
fn reverse_dns_lookups(
lookup_request_rx: &Receiver<(AddressPortPair, TrafficDirection, Vec<Address>)>,
lookup_result_tx: &Sender<HostMessage>,
mmdb_readers: &Option<MmdbReaders>
) {
while let Ok((key, traffic_direction, interface_addresses)) = lookup_request_rx.recv() {
// This is the only blocking call
let lookup_result = dns_lookup::lookup_addr(&address_to_lookup);
let msg_data = HostMessage {
host: lookup_result.ok().map(|s| Host::from((s, key, &traffic_direction))),
// ... additional fields
};
let _ = lookup_result_tx.send(msg_data);
}
}
Non-Blocking Result Collection
While parsing packets, the main thread periodically checks for completed lookups without waiting. The AddressesResolutionState struct manages this state, and its new_hosts_to_send method uses try_recv to poll the result channel:
// Inside AddressesResolutionState::new_hosts_to_send
while let Ok(mut host_msg) = self.lookup_result_rx.try_recv() {
// Process completed lookup
// ...
}
If no results are ready, try_recv returns immediately with an error (usually Empty), and the parser continues processing the next packet. This ensures the capture loop never stalls on DNS resolution.
UI Integration via Async Channels
Resolved hosts eventually reach the GUI through a separate async channel. The backend packages updates into BackendTrafficMessage variants and sends them via async_channel::Sender (tx):
// Inside maybe_send_tick_run_live or maybe_send_tick_run_offline
let _ = tx.send_blocking(BackendTrafficMessage::TickRun(
cap_id,
msg,
host_msg,
no_more_packets
));
GUI Subscription and Handling
In src/gui/sniffer.rs, the GUI subscribes to this channel using Task::run, converting backend messages into UI Message types that process on the main UI thread:
Task::run(rx, |backend_msg| match backend_msg {
BackendTrafficMessage::TickRun(cap_id, msg, host_msg, no_more_packets) => {
Message::TickRun(cap_id, msg, host_msg, no_more_packets)
}
BackendTrafficMessage::PendingHosts(cap_id, host_msg) => {
Message::PendingHosts(cap_id, host_msg)
}
BackendTrafficMessage::OfflineGap(cap_id, gap) => {
Message::OfflineGap(cap_id, gap)
}
});
Because the DNS work happens on the separate thread_reverse_dns_lookups thread, the UI thread never blocks on network I/O, maintaining 60 FPS responsiveness even during heavy traffic analysis.
Key Source Files Reference
| File | Role |
|---|---|
src/networking/parse_packets.rs |
Main packet-parsing loop, spawns DNS worker, coordinates channels |
src/gui/sniffer.rs |
GUI core; receives BackendTrafficMessage via async channel |
src/networking/types/host.rs |
Definition of Host and HostMessage for transporting resolved data |
src/networking/types/ip_collection.rs |
Holds AddressesResolutionState tracking pending lookups |
Summary
- Worker thread isolation: Sniffnet spawns a dedicated thread
thread_reverse_dns_lookupsinsrc/networking/parse_packets.rsto perform blockingdns_lookup::lookup_addrcalls. - Dual-channel architecture: Request channels carry
(AddressPortPair, TrafficDirection, Vec<Address>)tuples to the worker, while result channels returnHostMessagestructs. - Non-blocking polling: The parser uses
try_recvinAddressesResolutionState::new_hosts_to_sendto collect results without waiting. - Async UI updates: Resolved data flows through
async_channelto the GUI insrc/gui/sniffer.rs, processed viaTask::runto keep the main thread responsive.
Frequently Asked Questions
Why does Sniffnet use OS threads instead of async/await for DNS resolution?
Sniffnet uses std::thread instead of async tasks because dns_lookup::lookup_addr performs synchronous system calls that block the entire thread. By isolating these calls in a dedicated OS thread, the application can perform true parallelism while keeping the async runtime (used by the Iced GUI framework) unblocked for UI updates. Rust's channels provide the necessary synchronization between these thread domains.
What happens if a reverse-DNS lookup times out or fails?
When lookup_addr fails or times out (typically after OS-defined timeouts), the worker thread captures the Result and wraps it in a HostMessage. The message is sent back through lookup_result_tx regardless of success or failure. The UI layer handles missing hostnames gracefully, displaying the raw IP address when resolution fails, ensuring no indefinite blocking occurs even with unresponsive DNS servers.
How does the UI know when new host information is available?
The UI subscribes to an async_channel::Receiver created during application startup. When the DNS worker finishes lookups and the parser aggregates results, the backend sends BackendTrafficMessage::TickRun or BackendTrafficMessage::PendingHosts through the channel. The Task::run mechanism in src/gui/sniffer.rs converts these backend messages into UI Message variants, which the Iced framework processes on the next frame update.
Can this pattern be applied to other Rust GUI applications?
Yes. The pattern of spawning worker threads for blocking I/O and communicating results via channels is framework-agnostic and works with Iced, egui, Tauri, or other Rust GUI toolkits. The key is ensuring the main event loop uses try_recv (for sync channels) or async receivers (for async channels) to pull data without blocking, allowing the UI to maintain responsiveness while background work completes.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →