Destroying an HttpServer while another thread is inside listen() is a use-after-free, and the API gives the caller no way to avoid it.
Found while chasing an intermittent Process crashed in OpenDocument.droid's instrumented tests (odrcore 6.0.1, android x86_64 API 32, emulator). It is not android specific — the same race is reachable from plain C++ and from the JNI bindings.
The crash
Fatal signal 11 (SIGSEGV), code 128 (SI_KERNEL), fault addr 0x0 in tid 7413 (Thread-4)
#00 libodr_jni.so (httplib::Server::listen_internal()+1069)
#01 libodr_jni.so (odr::HttpServer::Impl::listen() const+532)
#02 libodr_jni.so (Java_app_opendocument_core_HttpServer_listenNative+16)
The app calls stop() and then close() on the java HttpServer; the thread that had called listen() segfaults a few seconds later, still inside httplib's accept loop.
Why
listen() blocks, so it can only be called on a thread of its own, and stop() does not wait for it:
// httplib.h
inline void Server::stop() {
if (is_running_) {
std::atomic<socket_t> sock(svr_sock_.exchange(INVALID_SOCKET));
detail::shutdown_socket(sock);
detail::close_socket(sock);
}
}
It shuts the listening socket down; listen_internal() notices on its next turn and returns some time later. Meanwhile ~Impl frees what that thread is standing on:
// src/odr/http_server.cpp
~Impl() {
m_stopping.store(true, std::memory_order_release);
if (m_server != nullptr) {
m_server->stop();
m_server.reset(); // Destroy server, join all thread pool threads
}
}
The comment is half right. httplib::Server::~Server() = default, so member destruction does join the thread pool — but the thread executing listen() is the caller's thread, which httplib neither owns nor can join. m_server.reset() therefore deletes the httplib::Server out from under listen_internal().
HttpServer::listen() not taking a reference to what it runs on is what makes it reachable:
void HttpServer::listen() const { m_impl->listen(); } // no shared_ptr copy
m_impl is a shared_ptr<Impl>, but listen() borrows it. Destroying the HttpServer on another thread drops the last reference and runs ~Impl immediately, mid-listen().
The caller cannot currently avoid it
odr::HttpServer exposes connect_service, bind, listen, clear, stop — there is no is_running(), no wait_until_stopped(), no join. cpp-httplib has is_running() and wait_until_ready(); neither is wrapped. So a caller has no way to ask whether listen() has actually returned, and the only sound approach left is to join the thread it started itself and hope the timeout was generous enough.
The JNI bindings make it sharper
NativeResource implements AutoCloseable and frees the handle both on close() and from a phantom-reference queue. Two consequences:
close() from any thread while a listen thread is running is this use-after-free. The idiomatic try (HttpServer s = new HttpServer()) { … } is unsafe for exactly the one method that has to run on another thread.
- "Just don't call
close()" is not a workaround either: letting the wrapper become unreachable runs Destroyer.destroy() → destroy(handle) → the same thing, at a moment the caller does not control. (In practice the JNI frame of the in-flight listenNative call keeps the instance reachable, so the GC path is narrower than the explicit one — but it is not something a caller should have to reason about.)
There is no HttpServer.isRunning() on the java side either.
What we did in the app
Not a fix, just damage control — OpenDocument.droid#548. CoreLoader.close() now hands the teardown to a short-lived daemon thread that joins the listen thread with a timeout, calls HttpServer.close() only if that thread actually came back, and otherwise keeps the wrapper in a static list forever so that neither close() nor the reference queue can free it. Leaking one server beats a SIGSEGV, but it is guesswork built on a timeout.
Suggestions
- Make
listen() keep its Impl alive — auto impl = m_impl; impl->listen();. Then ~Impl cannot run, and m_server cannot be freed, until listen() returns. That alone closes the use-after-free for both the C++ and the JNI path, and is a one-line change.
- Expose the state:
is_running() (httplib already has it) and ideally a wait_until_stopped(timeout), so stop() can be followed by something better than a sleep.
- Consider making
stop() synchronous, or documenting loudly that it is not, and that ~HttpServer must not run while listen() is in flight.
- Fix the
~Impl comment — destroying httplib::Server does not join the thread that called listen().
Happy to send a PR for (1) and (2) if that is the direction you want.
Destroying an
HttpServerwhile another thread is insidelisten()is a use-after-free, and the API gives the caller no way to avoid it.Found while chasing an intermittent
Process crashedin OpenDocument.droid's instrumented tests (odrcore 6.0.1, android x86_64 API 32, emulator). It is not android specific — the same race is reachable from plain C++ and from the JNI bindings.The crash
The app calls
stop()and thenclose()on the javaHttpServer; the thread that had calledlisten()segfaults a few seconds later, still inside httplib's accept loop.Why
listen()blocks, so it can only be called on a thread of its own, andstop()does not wait for it:It shuts the listening socket down;
listen_internal()notices on its next turn and returns some time later. Meanwhile~Implfrees what that thread is standing on:The comment is half right.
httplib::Server::~Server() = default, so member destruction does join the thread pool — but the thread executinglisten()is the caller's thread, which httplib neither owns nor can join.m_server.reset()therefore deletes thehttplib::Serverout from underlisten_internal().HttpServer::listen()not taking a reference to what it runs on is what makes it reachable:m_implis ashared_ptr<Impl>, butlisten()borrows it. Destroying theHttpServeron another thread drops the last reference and runs~Implimmediately, mid-listen().The caller cannot currently avoid it
odr::HttpServerexposesconnect_service,bind,listen,clear,stop— there is nois_running(), nowait_until_stopped(), no join. cpp-httplib hasis_running()andwait_until_ready(); neither is wrapped. So a caller has no way to ask whetherlisten()has actually returned, and the only sound approach left is to join the thread it started itself and hope the timeout was generous enough.The JNI bindings make it sharper
NativeResource implements AutoCloseableand frees the handle both onclose()and from a phantom-reference queue. Two consequences:close()from any thread while a listen thread is running is this use-after-free. The idiomatictry (HttpServer s = new HttpServer()) { … }is unsafe for exactly the one method that has to run on another thread.close()" is not a workaround either: letting the wrapper become unreachable runsDestroyer.destroy()→destroy(handle)→ the same thing, at a moment the caller does not control. (In practice the JNI frame of the in-flightlistenNativecall keeps the instance reachable, so the GC path is narrower than the explicit one — but it is not something a caller should have to reason about.)There is no
HttpServer.isRunning()on the java side either.What we did in the app
Not a fix, just damage control — OpenDocument.droid#548.
CoreLoader.close()now hands the teardown to a short-lived daemon thread that joins the listen thread with a timeout, callsHttpServer.close()only if that thread actually came back, and otherwise keeps the wrapper in a static list forever so that neitherclose()nor the reference queue can free it. Leaking one server beats a SIGSEGV, but it is guesswork built on a timeout.Suggestions
listen()keep itsImplalive —auto impl = m_impl; impl->listen();. Then~Implcannot run, andm_servercannot be freed, untillisten()returns. That alone closes the use-after-free for both the C++ and the JNI path, and is a one-line change.is_running()(httplib already has it) and ideally await_until_stopped(timeout), sostop()can be followed by something better than a sleep.stop()synchronous, or documenting loudly that it is not, and that~HttpServermust not run whilelisten()is in flight.~Implcomment — destroyinghttplib::Serverdoes not join the thread that calledlisten().Happy to send a PR for (1) and (2) if that is the direction you want.