Skip to content

HttpServer has no safe teardown: stop() double-closes the socket, and destroying it races listen() #631

Description

@andiwand

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

  1. Make listen() keep its Impl aliveauto 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.
  2. 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.
  3. Consider making stop() synchronous, or documenting loudly that it is not, and that ~HttpServer must not run while listen() is in flight.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions