Skip to content

fix(http-server): make stop() and destruction wait for listen() - #633

Merged
andiwand merged 1 commit into
mainfrom
fix/http-server-teardown
Jul 30, 2026
Merged

fix(http-server): make stop() and destruction wait for listen()#633
andiwand merged 1 commit into
mainfrom
fix/http-server-teardown

Conversation

@andiwand

@andiwand andiwand commented Jul 30, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Closes #631.

What was wrong

Both crashes in the issue have the same root: stop() and ~Impl dropped the
httplib::Server right after asking it to stop, while the caller's thread was
still inside listen_after_bind().

m_server->stop();
m_server.reset(); // Destroy server, join all thread pool threads

httplib joins its own thread pool there. The thread that called listen() is
the caller's — httplib neither knows about it nor can join it — so the accept
loop was left standing on freed memory. That is the SIGSEGV.

It is also the fdsan abort, which I think is a consequence rather than a second
bug. Server::stop() exchanges svr_sock_ to INVALID_SOCKET before closing
it, so the close_socket(svr_sock_) on the way out of the accept loop reads
INVALID_SOCKET and does nothing — as long as the server is still there to read
from. Once it has been freed, that read returns whatever the allocator left
behind, and listen_internal() closes a descriptor that by then belongs to
something else in the process. Hence owned by ZipArchive.

The fix

listen() takes a reference to the impl and to the httplib server of its own,
and stop() closes the socket and then waits for the accept loop to be out
before releasing either. Nothing is serving once stop() returns, so there is
nothing left to race — no join, no timeout, no guessing on the caller's side.
Destroying the last handle does the same, which is what makes
try (HttpServer s = new HttpServer()) safe around a listen thread.

Two orderings that were not handled at all:

  • httplib::Server::stop() closes nothing until listen_internal() has the
    accept loop up, and asserts (then closes an already closed descriptor) if it
    runs a second time after that. A stop() that overtakes a starting listen()
    now waits for the loop rather than stopping twice or hanging.
  • A listen() that starts after stop() now returns instead of serving a
    socket that is already gone.

The public header is unchanged apart from is_running() — no new member, no new
type. A HttpServer holds a second shared_ptr to the same impl whose deleter
stops the server instead of destroying it, with the owning reference captured in
the deleter. shared_from_this() stays bound to the control block make_shared
put there (a later one only takes weak_this over once it has expired), so a
listen() in flight holds that reference and does not keep the handles alive.
Running out of handles therefore stops, and ~Impl only tidies up after it.

Also exposes is_running() — the state the caller had no way to ask about — in
C++, java and python, and fixes the ~Impl comment that claimed destroying the
httplib server joined the listen thread.

Bindings

  • java: isRunning(), and a new GuardedNativeResource — a NativeResource
    whose handle stays valid for the duration of a native call even if another
    thread closes it. Stopping alone is not enough: a listen thread that has read
    the handle but not reached listenNative yet is invisible to the native
    stop(), so close() also waits for the call to hand the handle back, having
    first called unblock() (stop() here) to make it come back at all.

    It is a separate base class rather than a change to NativeResource because
    the hazard is caller error everywhere else — you do not use an object while
    you close it — and listen() is the one call the API asks you to make on a
    thread of its own. Classes that don't need it keep the plain handle(), with
    no extra fields, no lock, and nothing on Element's 34 native calls.

  • python: is_running(), docstrings for the new listen/stop contract.
    stop already released the GIL, which it now needs to: it waits for the
    listen thread, which needs the GIL back to return out of listen.

Tests

New gtest cases: stop returns only once listen has, stop before the accept
loop is up, stop twice, destroying the last handle while listening, listen
after stop. Mirrored in the JUnit and pytest suites, plus a closeStopsListen
android instrumented test — fdsan there is what turns a double close into a hard
abort rather than a silent one.

Verified against the old stop() body: stop_returns_only_once_listen_has
fails on it. Ran locally on macOS — gtest (16k iterations of the race cases),
the JNI JUnit suite and pytest.

What is not fixed

A server that was bound but never listened still keeps its port until the
process ends: cpp-httplib closes the listening socket only from its accept loop
and exposes no way to reach it. That was already documented on stop() and
still is.

@andiwand
andiwand force-pushed the fix/http-server-teardown branch from 6473b35 to 6dda572 Compare July 30, 2026 18:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6473b355bf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread jni/java/app/opendocument/core/HttpServer.java Outdated
`stop()` and `~Impl` both dropped the `httplib::Server` right after asking
it to stop, while the caller's thread was still inside `listen_after_bind()`.
httplib only joins its own thread pool there - the thread that called
`listen()` is the caller's, which it can neither know about nor join - so the
accept loop was left standing on freed memory. That is the SIGSEGV in #631,
and also the fdsan abort: reading `svr_sock_` out of the destroyed server
hands `listen_internal()` a stale descriptor to close, which by then belongs
to something else in the process.

`listen()` now takes a reference to the impl and to the httplib server of its
own, and `stop()` closes the socket and then waits for the accept loop to be
out before releasing either. Nothing is serving once it returns, so there is
nothing left to race - no join, no timeout, no guessing on the caller's side.
Destroying the last handle does the same, which is what makes java's
try-with-resources safe around a listen thread.

Two orderings that were not handled at all:

- `httplib::Server::stop()` closes nothing until `listen_internal()` has the
  accept loop up, and asserts (then closes a closed descriptor) if it runs a
  second time after that. A `stop()` that overtakes a starting `listen()` now
  waits for the loop rather than stopping twice or hanging.
- A `listen()` that starts after `stop()` returns instead of serving a socket
  that is already gone.

Also exposes `is_running()` - the state the caller had no way to ask about -
in C++, java and python, and fixes the `~Impl` comment that claimed
destroying the server joined the listen thread.

Closes #631

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEvpyHCo15bkwhKgsEviGi
@andiwand
andiwand force-pushed the fix/http-server-teardown branch from 457eefc to d5d5924 Compare July 30, 2026 21:07
@andiwand
andiwand merged commit 9729b2a into main Jul 30, 2026
26 checks passed
@andiwand
andiwand deleted the fix/http-server-teardown branch July 30, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant