Concurrency contract, mutex inventory, and lock ordering for libhttpserver v2.0. Decision record:
specs/architecture/11-decisions/DR-008.md· cross-cutting §5.1 / §5.6:05-cross-cutting.md.
DR-008 chose Option 1 — internally synchronized, fully re-entrant. Every webserver public method is thread-safe and may be called from inside a handler, including live route (un)registration, IP allow/deny, and hook registration.
- Handlers run concurrently on MHD worker threads — the same lambda /
http_resource/ hook is invoked from many threads at once. Your own state must be user-synchronized. http_requestis single-threaded per request — owned by the servicing worker, must not outlive the handler return or be shared across threads (UB).http_responseis a move-only value type; returning it transfers ownership.- The one exception to "call anything, anytime":
stop(),stop_and_wait(), and~webserver()must not be called from a handler thread — they join MHD's workers (the very thread calling them) and will deadlock, or abort with "Failed to join a thread." on some MHD builds. Call them from the thread that owns the server. - Cache-staleness caveat: the route LRU cache is consulted outside
route_table_mutex_, so a request that hit the cache can still be served by a just-unregistered resource. This is not use-after-free (the cache holds ashared_ptr); the single response is merely logically stale.
Verified by test/integ/threadsafety_stress.cpp under the TSan CI lane (16 curl clients × 60 s mutating routes/IPs from handler threads), plus the opt-in stop_from_handler_deadlocks_as_documented negative test.
flowchart TB
subgraph internal["Internal threading · default"]
direction TB
M1["MHD polling / thread-per-connection"] --> W1["worker threads"]
W1 --> H1["handlers · hooks · log callbacks<br/>run concurrently"]
end
subgraph external["External event loop"]
direction TB
L1["your select / epoll loop"] --> R1["run · run_wait · get_fdset · add_connection"]
R1 --> H2["handlers run on YOUR loop thread"]
end
- Internal (default;
start_method,max_threads): MHD owns the threads.max_threads != 0addsMHD_OPTION_THREAD_POOL_SIZE. All user callbacks — render methods, hooks,log_access/log_error, error handlers, PSK/SNI/ALPN,file_cleanup— run on MHD workers, concurrently. - External: start without internal threads and drive with
run()/run_wait(ms)/get_fdset()+get_timeout()/add_connection(). Dispatch runs on your loop thread;run()/run_wait()catch handler exceptions internally. daemon_lifecyclestart/stop handshake uses POSIX primitives (mutexwait/mutexcond,pthread_*) rather thanstd::because MHD's start path expects them. On a blockingstart()the callerpthread_cond_waits untilstop()flipsrunning=falseand signals.
Each state collaborator owns its own mutex(es); no lock nests with another across a call-out (see below). shared_mutex gives many-reader / one-writer.
| Owner | Member | Type | Guards | Read / write |
|---|---|---|---|---|
route_table |
route_table_mutex_ |
shared_mutex |
the 3 route tiers (exact_routes_, param_and_prefix_routes_ trie, regex_routes_) |
lookup = shared; (un)register = unique via lock_for_write() |
route_cache |
mutex_ |
mutex |
LRU list + index (every touch splices → a write) | lock_guard on every access |
hook_bus |
hook_table_mutex_ |
shared_mutex |
the 11 server-wide phase vectors | add/remove = unique; fire = shared (snapshot + release) |
hook_bus |
any_hooks_[11], next_slot_id_ |
atomic<bool>[], atomic<u64> |
hot-path skip gate; slot allocator | relaxed load on dispatch; set under unique |
resource_hook_table |
hook_table_mutex_ |
shared_mutex |
the 5 per-resource phase vectors | append/remove = unique; fire = shared |
ip_access_control |
deny_list_mutex_ |
shared_mutex |
deny_list_ |
mutate = unique; classify = shared |
ip_access_control |
allow_list_mutex_ |
shared_mutex |
allow_list_ |
mutate = unique; classify = shared |
ws_registry |
mutex_ |
shared_mutex |
handlers_ map |
(un)register = unique; find/empty = shared |
daemon_lifecycle |
mutexwait / mutexcond |
pthread_mutex_t / pthread_cond_t |
blocking start/stop handshake | raw pthread_* |
daemon_lifecycle |
daemon, running |
atomic<MHD_Daemon*>, atomic<bool> |
published handle + ephemeral port; started flag | release/acquire; lock-free reads |
http_resource |
cached_allow_mutex_ |
mutable shared_mutex |
only the lazy Allow:-header cache (cached_allow_header_/mask_/valid_) — not methods_allowed_ |
warm read = shared; fill = unique (double-checked) |
http_resource |
hook_table_ |
shared_ptr via std::atomic_* |
lock-free publication of the per-resource hook table | atomic_load(acquire) / CAS(acq_rel) |
webserver_impl |
sni_credentials_mutex |
mutable shared_mutex (HAVE_GNUTLS + cert-callback) |
sni_credentials_cache |
read = shared; fill = unique |
http_request_impl has no mutex — single-threaded per request by contract. segment_trie has none — it is covered by the owning route_table_mutex_.
Two facts hold together:
- The clusters are disjoint. IP-ACL,
ws_registry, and SNI mutexes are never taken with any other cluster's lock. The single deliberate intra-class co-hold isip_access_control::classify(), which takes bothdeny_list_mutex_andallow_list_mutex_asshared_lock, always deny-then-allow, for a consistent snapshot — never with any third mutex held. - The two nesting families have a documented order — but never hold two across a call-out. Each site takes a lock, snapshots the data, releases, then acts. This release-before-acquire is exactly what makes
add_hook/hook_handle::removesafe to call from inside a running hook.
flowchart LR
RT["route_table_mutex_"] -->|then| RH["resource hook_table_mutex_"] -->|then| SH["server hook_table_mutex_"]
- Route family:
route_table_mutex_beforeroute_cache::mutex_— but lookup releases the table lock then promotes into the cache; registration releases then clears it. - Hook family:
route_table_mutex_→ resourcehook_table_mutex_→ serverhook_table_mutex_. Each firing site copies the phase vector under ashared_lock, releases, then invokes user code with no lock held.
TSan-validated by threadsafety_stress.cpp and the check-route-table-concurrency lane.
| Safe from any thread, incl. inside a handler | Must be called only from the owning thread |
|---|---|
register_path · register_prefix · on_get/on_post/… · route |
stop() |
unregister_path · unregister_prefix · unregister_resource |
stop_and_wait() |
register_ws_resource · unregister_ws_resource |
~webserver() |
deny_ip · remove_denied_ip · allow_ip · remove_allowed_ip |
|
add_hook · http_resource::add_hook · hook_handle::remove |
A duplicate registration from a handler throws std::invalid_argument — no data race. Lifecycle atomics: running lets is_running()/stop() read lock-free while start/stop writes under the mutex; daemon (release/acquire) publishes the handle and ephemeral bind port so get_bound_port() is safe on another thread during a blocking start().
Policy (mirrors DR-008): any race Helgrind reports inside libhttpserver's own locking is a real bug — fix the source, never suppress it. Only narrow, per-symbol, third-party or provably-benign entries are allowed in test/valgrind-helgrind.supp — never a ... wildcard spanning a library locking frame.
The false positives come from MHD signalling its workers over an internal ITC pipe (not a pthread primitive), which Helgrind's --history-level=approx cannot see as a happens-before — so it flags benign races on MHD worker frames, immutable-after-start config reads, and state published via the HTTP round-trip. DR-014 split dispatch into its own TUs, changing inlining and lifting the write frames, which forced re-anchoring those suppressions on the test-fixture handler symbol (e.g. *print_request_resource*render_get*) rather than any library frame — so a genuine library race stays unsuppressed. scripts/check-dr008-lanes.sh is a structural gate asserting the two DR-008 CI steps (check-route-table-concurrency on TSan, check-stop-from-handler on the baseline lane) are never silently dropped. TSan (precise) is green across all of these.
See also: request-flow (which callbacks run on which thread) · class map (the collaborators that own these mutexes).