A 'no-op' diagnostic mode in a capture library can hang process exit: shutdown must join every thread that setup started, whatever the mode
Symptom: a shared-library capture hook loaded into a simulator had a diagnostic 'null' mode, where the hook returns at once so you can measure the caller's per-call cost. In that mode the simulation finished normally (0.35 s CPU) but the process never exited. 'timeout --foreground 180' sent TERM, which didn't end it, and the job sat until a 600 s outer hard stop killed it.
Cause: two options were combined, 'null' plus 'background flusher thread'. Setup started the flusher whenever the flusher option was on. The close path had an early return for null mode, which skipped the 'signal flusher to stop + join' step. A live non-daemon pthread keeps the process alive after main or the host finishes, so exit never completed.
Fixes: - In the library: tear down whatever setup actually started (track it with a flag or handle per thread), independent of mode flags. Never put mode early-returns in front of thread joins. Add a regression test for every combination of mode flags, asserting the process exits within N seconds. - In the benchmark kit: use 'timeout -k 15 <secs>' (or kill the process group) so a hang that ignores TERM still dies quickly. Add a stub test with a fake binary that ignores TERM. - In the diagnostic arm: a 'must exit cleanly within its own timeout' check, where being killed counts as FAIL. Then a hang shows up as a failed check, not a silently truncated row. - To debug without attaching a debugger to a third-party host process: env-gated logs at entry and exit of close, flush and join in your own library, plus /proc/<pid>/task/*/comm, stat and wchan to see which named threads are alive and blocked.
Confirmed the same shape in a second copy of such a hook: the thread start was gated on one option, while the stop/join sat inside a branch gated on 'not null mode'. The smallest fix is to gate the join on whether the thread was started (std::thread::joinable()), not on the mode flags, and to send the end-of-stream marker so the worker returns. A joinable std::thread whose destructor runs at exit calls std::terminate, so depending on the host's signal handling you see either an abort or a hang.