Internals Threading - NormB/sipnab GitHub Wiki
The topology below is the reality on main today.
- The core principle is design decision D2.
- The packet path is synchronous threads + channels.
- tokio exists only inside the optional servers.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ shared state โ
โ Arc<RwLock<DialogStore>> Arc<RwLock<StreamStore>>
โ (parking_lot, single writer) โ
โโโโโโโโโฒโโโโโโโโโโโโโโโโโโโโโโโโโโโฒโโโโโโโโโโโโโโโโ
write โ read โ (try_read in TUI)
capture thread(s) โ โ
live device / file / HEP โ โ
(capture::start_multi_capture) โ
โ Packet โ โ
โผ โ โ
capture::channel โโโโโบ processing thread โโโโโโโบ TUI event loop (main thread)
(capped channel) (TUI mode: pipeline:: crossterm events, render
process_packet; TUI MODE ONLY
batch mode: main.rs loop
on the main thread)
auxiliary threads, by the RUN MODE that starts each โ none of them is a
child of the TUI event loop:
both modes
โโโ server runtime thread app::servers::start_servers
โ one shared current-thread tokio runtime hosting every enabled
โ async server as a task: api (axum), mcp (rmcp), mcp-http
โโโ metrics-server thread app::servers::start_servers
โ raw std::net::TcpListener accept loop + one short-lived
โ metrics-conn thread per scrape
โโโ DNS resolver thread app::build_resolver
โ std::mpsc queue; spawned only when reverse DNS is enabled
โโโ rtpengine reconciler app::relay_reconciler::spawn
bounded hand-off queue; starts only when --rtpengine-control
names a relay on a live run
batch mode only
โโโ scanner-kill worker process_isolation::spawn_scanner_kill_worker
a separate PROCESS (this binary, re-executed), started by
bootstrap::launch before the chroot and privilege drop; two
threads here serve its pipes; --kill-scanner has no TUI path
The mode column above is the part to keep true. It is worth saying why.
- An earlier revision of this diagram drew every auxiliary thread as a child of the TUI event loop.
- Not one of them is.
- Both run modes start four of them.
- Batch alone starts the scanner-kill worker.
That is not a cosmetic error.
-
--metricsreally did ship wired to the TUI path alone. - So every headless
-Ndeployment got no metrics at all.- That is every container and systemd unit.
- This page said nothing that would have contradicted it.
- A diagram can group threads by who spawns them rather than by which mode
runs them.
- Such a diagram reads as reassurance.
- It hides exactly that class of defect.
The four spawn sites, none of which is the TUI event loop:
-
start_servers()for the server runtime and the metrics listener. -
build_resolver()for the reverse-DNS worker. -
spawn_scanner_kill_worker()for the kill worker. -
relay_reconciler::spawn()for the rtpengine reconciler.
The Prometheus listener is not a task on the shared tokio runtime. The distinction matters when reasoning about blocking.
- It is a raw
TcpListeneraccept loop on its own thread. - It is deliberately independent of tokio and axum.
- So metrics stay scrapable when the async servers are not compiled in at all.
Each accepted scrape runs on a short-lived metrics-conn thread.
- A
ConnGatebounds those to 16 in flight. - Beyond that, new connections get an immediate
503rather than a thread (SN-02, CWE-770). -
start_metrics_server()spawns the thread itself. -
start_serverscalls it for both run modes.
Every long-lived thread and its spawn site. A name in a backtrace maps straight back to a row here.
Modes is the column to trust when asking "does this run headless?"
- That is the question
--metricsgot wrong. -
bothmeans the thread exists under-Nas well as in the TUI. -
TUIandbatchmean it does not.
| Thread name | Modes | Spawned by | Role |
|---|---|---|---|
capture-<device> |
both | capture/native.rs |
One per live device: pcap loop producing Packets. |
capture-stop:<device> |
both | capture/breaker.rs |
Beside each live capture: calls pcap_breakloop when a stop is due, for reads that never return by themselves (libpcap's netmap module on a silent link). Joined when its capture ends. |
capture-file |
both | capture/native.rs |
Offline pcap reader feeding the same channel as live capture. |
capture-hep |
both | capture/native.rs |
HEP/EEP receiver on the transport --hep-listen-transport names; packets carry asserted addresses and carry a flag HEP-origin. On udp this thread reads the datagrams itself. On tcp and tls it is the accept loop, and the reading happens in the per-connection threads below. |
| (unnamed per-connection readers) | both | capture/hep.rs |
One scoped thread per accepted HEP stream connection under --hep-listen-transport tcp or tls, up to HEP_MAX_STREAM_CONNECTIONS, so the listener serves several agents at once and one silent peer cannot stall the rest. Scoped, so no reader outlives the listener; each polls the stop flag on its read timeout, which is what bounds the scope's join. A TLS connection completes its handshake here rather than in the accept loop, for the same reason. |
capture-multi |
both | capture/native.rs |
Supervisor for a multi-device capture set. |
tui-processor |
TUI | app/tui_mode.rs |
The single store writer in TUI mode: drains the channel and runs the pipeline. |
| (unnamed workers) | batch | parallel.rs |
--cores N reconstruction workers, spawned with bare thread::spawn โ they own thread-local stores, so they show as unnamed in a backtrace. |
| (unnamed per-file readers) | batch | parallel.rs |
--cores N over a multi-file -I set: one scoped thread per reader, each reading whole files and handing per-shard batches to the dispatcher, which releases them to the workers in file order. A single-file -I has one file and therefore one reader, so it gets none of these โ the calling thread reads it, exactly as before. |
servers |
both | app/servers.rs |
One current-thread tokio runtime hosting api, mcp and mcp-http as JoinSet tasks. |
metrics-server |
both | output/prometheus_server.rs |
Raw TCP accept loop for Prometheus scrapes. |
metrics-conn |
both | output/prometheus_server.rs |
One short-lived thread per accepted scrape, capped at 16 concurrent. |
sipnab-dns |
both | names.rs |
Reverse-DNS resolver draining an std::sync::mpsc queue so the render path never blocks on a lookup. Starts only when the run turns reverse DNS on. |
scanner-kill-fwd, scanner-kill-rx
|
batch | process_isolation.rs |
The parent's half of the scanner-kill worker, which is a process of its own: -fwd writes queued kill requests into the worker's request pipe (the one write that can block, so it is never the capture thread's), -rx reads its outcomes into the tally. The worker process runs the decision loop on its main thread between two pumps, kill-worker-in and kill-worker-out; it is the only process that answers an address the capture supplied, and the process parsing captured traffic holds none of its send sockets. --kill-scanner has no TUI path. |
rtpengine-reconcile |
both | app/relay_reconciler.rs |
Asks an rtpengine relay which calls it holds, over the relay's read-only list and query, so a stream the signaling does not explain still gets a Call-ID. Starts only when --rtpengine-control names a relay on a live run, and keeps the round trip off the packet path. |
pcap-load |
TUI | tui/controllers/file_open.rs |
Loads a pcap chosen from inside the TUI, writing the live stores. |
clipboard |
TUI | tui/clipboard.rs |
Holds the X11/Wayland selection alive after a copy without stalling the UI. |
crash-probe |
tests | crash.rs |
Not a production thread. Only mod tests spawns this name, to prove the panic hook records a thread name. It appears in no shipped backtrace. This row exists so the next reader who greps the name does not re-add it as one. |
Every fatal exit taken after start_capture()
has to go through stop_and_join().
There is no second correct way out.
The reason is where the thread starts.
-
bootstrap::launch()spawns it before these steps:- The readiness hand-shake.
- The chroot.
- The privilege drop.
- The reason: the capture source must be open while the process still holds
CAP_NET_RAW. - Every failure from that point on happens with a capture thread already
running and holding its source. Such failures include:
- An unopenable source.
- An unusable
--chroot. - A refused privilege drop.
- A hardening step the kernel rejects.
- A companion server that cannot start.
-
std::process::exitjoins nothing and runs no destructors.- So exiting directly abandons it.
stop_and_join does three things:
- It sets the global shutdown flag.
- It drops the packet receiver.
- It joins.
Both signals matter, and neither is redundant.
- Dropping the receiver ends a thread blocked on a send.
- The flag ends one blocked elsewhere in its loop.
- A live capture waiting out its read timeout reaches the flag check first.
- One read never times out.
- libpcap's netmap module waits inside
pcap_next_exuntil a frame arrives. - So on a silent link the flag check is never reached.
- For that read, the capture's
capture-stop:<device>thread sees the same flag and callspcap_breakloop. - The loop takes the broken read as a stop.
- libpcap's netmap module waits inside
- The join has no timeout.
- That matches the one the batch receive loop already performs at end of capture.
- A HEP listener blocked on its socket returns in milliseconds rather than hanging it.
One consequence applies to callers that cannot reach the handle.
-
BatchRunner::new()does not own it. - So its four fatal paths return a
PlanErrorinstead of exiting. -
batch::runholds both the handle and the receiver. It does the teardown. - A function that cannot clean up must hand the failure to one that can.
How a gate holds this.
- ThreadSanitizer treats
thread leakas a fatal finding, not a warning. -
cli_flag_behavior_testexercises both shapes:- A source that never opens.
- A failure after it opened.
- So a regression fails there, rather than waiting for the weekly sanitizer run.
- Before this rule,
sipnab -I /nonexistent.pcap(a mistyped filename) leaked a thread.
Opening a pcap from inside the TUI spawns a second writer.
- The
pcap-loadworker writes the same stores the render thread is reading. - That is why every render-side access is
try_read()and neverread().
sequenceDiagram
autonumber
participant UI as TUI event loop
participant Load as pcap-load worker
participant DS as DialogStore
participant SS as StreamStore
UI->>Load: spawn on O (file open dialog)
UI->>DS: try_read for the frame
DS-->>UI: guard, or skip the frame
Load->>DS: write lock
DS-->>Load: release
Load->>SS: write lock
SS-->>Load: release
Load-->>UI: progress via async_messages
Note over UI,SS: the render pass never blocks โ a contended try_read skips a frame
--cores N offline mode replaces the single processing thread with a
dispatcher + N workers.
- The dispatcher does a cheap host-pair peek.
- It shards raw packets over bounded channels.
- Each worker owns a private
PacketProcessor+ thread-localDialogStore/StreamStore.- There are no locks on the hot path.
- The stores merge at EOF.
- Flow correctness holds because a flow's packets share a host pair.
- Therefore they share a worker.
- A call's SIP and its RTP can run between different hosts, and so on
different workers.
- The dispatcher also sends a copy of each packet that can carry SDP to every other worker, at the same place in that worker's sequence.
- A worker reads a copy only for the media sockets its SDP names, and counts nothing from it.
- So each worker's
StreamStoresees a later call take over a socket and an SSRC at the point in the capture where it did, and starts that call's stream there, as the single-threaded path does.
-
Parse outside the lock.
- SIP/RTP/SDP parsing happens before any store lock appears.
-
classify_packet()touches no store at all. - Each store is then locked for writing once per packet.
- That makes writes the most frequent lock operation in the process, not the rarest.
-
Lock ordering:
- When a path needs both stores, dialog store first, then stream store.
- If it also needs the alert engine, that comes last.
- The ordering is what carries the safety. The guards are not always disjoint.
- Two paths each take one store at a time and release it before the other.
"briefly" is accurate there.
-
process_packet()on the live path. -
run_pcap_load()for the file-open worker above.
-
- The batch applier (
batch.rs) does not work that way.- It holds both write guards across the entire per-packet body.
- This page previously said both write locks were never held at once. That was never true of the batch path.
-
The guards queue side effects rather than performing them.
- These accumulate while the code holds the guards:
- Alert findings.
- Per-message output.
- The
--alert-exec/--on-dialog-exec/--on-quality-execspawns.
-
DeferredEffects::drainreplays them after both drop. - So none of these happens inside the critical section:
- A
fork/exec. - A stdout write.
- An
AlertEnginelock.
- A
- Until that change, the batch loop took the alert engine's write lock nested
inside both store guards.
- That was a third lock deep in the hot path, with a
posix_spawnbeside it.
- That was a third lock deep in the hot path, with a
- The full rule and what would re-create that edge are in Invariants, "Dialog before stream, then alerts โ one consistent order".
- These accumulate while the code holds the guards:
-
The TUI never blocks: all render-side store access is
try_read().- On contention the frame renders with the previous data.
- Counts may be one frame stale. This is deliberate.
- An adaptive 10 fps active / 2 fps idle tick bounds staleness.
-
Pause is an
AtomicBoolchecked by the processing thread. No lock. -
mcp/deniesclippy::await_holding_lock.- parking_lot guards must never live across an
.await.
- parking_lot guards must never live across an
| Edge | Flavor |
|---|---|
| capture โ processing |
capture::channel โ a count-capped permit pool over an unbounded crossbeam channel, so idle memory returns to ~0 while backpressure still blocks the sender. A live source pays one channel item per packet (the kill path reacts to single packets); a file source sends batches of 128 through the same channel, one item per batch, with the slot pool divided to keep the same in-flight packet cap |
| batch main loop | the same capture::channel wrapper, not a bare bounded channel: batch.rs receives on a capture::channel::PacketRx exactly as the TUI processor does |
--cores dispatcher โ workers, channel-fed |
crossbeam bounded::<Packet>(8192) (run_offline_parallel()) |
--cores reader โ workers, file input |
crossbeam bounded::<Vec<Packet>>(64) carrying batches of 128 (run_offline_parallel_file()) โ same ~8192 in-flight packet cap, one channel hop per 128 packets instead of per packet |
--cores per-file reader โ dispatcher, multi-file input |
crossbeam bounded carrying one batch per item (shard_set_parallel()). The queue depth is not the bound that matters: READ_AHEAD_BYTES caps in bytes how far a LATER file's reader may run ahead of the file the dispatcher holds, because a batch is 128 packets of any size and the default snaplen is 65535 |
| scanner-kill request / response | crossbeam bounded(256) from the capture thread to scanner-kill-fwd, then pipes to and from the worker process, then bounded(256) for the outcome stream (process_isolation.rs) |
| packet path โ rtpengine reconciler |
std::sync::mpsc::sync_channel(1024) (orphan_channel()) โ the capture path offers a relay-side socket and never waits on it: a full queue drops the offer and counts it, because stalling packet processing on a relay round trip is the trade --rtpengine-control exists to avoid |
| DNS resolve queue |
std::sync::mpsc (names.rs) |
| inside api/mcp servers | tokio (axum/rmcp internals) |