feat(http-server)!: split listen into bind and listen - #624
Merged
Conversation
listen(host, port) took the address, blocked, and returned void - discarding the bool cpp-httplib hands it. A failed bind was therefore known inside the library and dropped twice, leaving the caller with a server that was never listening and no way to find out. OpenDocument.droid hit exactly that: both flavors hardcode one port, the second app to start silently had no server, and every document ended up behind chrome's error page. It cost a day to diagnose from the outside. bind() now takes the address, reports failure as ServerBindFailed, and returns the port it actually got - so port 0 becomes usable and callers no longer have to probe for a free port and race with it. listen() serves what bind() opened. Since cpp-httplib calls ::listen() during the bind, connections are accepted into the backlog from the moment bind() returns, which removes the "is it up yet?" window between starting a thread and serving on it. Two states that cpp-httplib handles silently are now errors: - binding twice replaced its socket and leaked the first one, holding that port until the process ended. ServerAlreadyBound. - listen_after_bind() on a server that never bound returns *true* and does nothing at all - the loop condition is simply false. ServerNotBound. Socket options move onto bind() as HttpServer::Options, since they belong to the socket rather than the server. They also correct a default: cpp-httplib sets SO_REUSEPORT where it exists and SO_REUSEADDR only otherwise, which is the wrong way round for a server that gets restarted. Only SO_REUSEADDR lets a port still held by TIME_WAIT sockets be bound again - the failure above - while SO_REUSEPORT hands a second server a share of the connections instead. Now reuse_address defaults on and reuse_port off. Callers updated: the CLI binds before printing its urls and prints the port it got; the jni and python bindings gain bind() and Options; their tests drop the free-port dances they needed to work around the old API. BREAKING CHANGE: HttpServer::listen(host, port) is replaced by bind(host, port) + listen(). Java and Python mirror the split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017sjMgQ5jJuzvE6ErSNuirb
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fe3aaad83e
ℹ️ 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".
Config held nothing but cache_path, which existed only for the serve_file marked TODO remove, and for a clear() that deleted the caller's translated files underneath them. Both go: the server hosts what it is given, and what a service was translated into belongs to whoever translated it. Config stays as an empty struct so server-wide settings have somewhere to land. Callers translate for themselves now - html::translate() plus connect_service(), which is what serve_file did anyway - and the CLI picks its own cache directory instead of inheriting one from the server. Also fixes the socket options on windows, from review: the callback replaced cpp-httplib's defaults wholesale, and on windows those include SO_EXCLUSIVEADDRUSE. The flags mean the opposite there - SO_REUSEADDR lets a second live socket take the endpoint over and SO_EXCLUSIVEADDRUSE is what keeps it ours - so setting only SO_REUSEADDR would hand the port away, which is the hazard reuse_port=false exists to avoid. Windows keeps the defaults and Options is documented as posix only. BREAKING CHANGE: HttpServer::Config::cache_path, HttpServer::config() and HttpServer::serve_file() are gone. Translate with html::translate() and host the result with connect_service(). Java and Python mirror this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017sjMgQ5jJuzvE6ErSNuirb
Both structs move to namespace scope, next to HtmlConfig which is spelled that
way already, and the class keeps them as aliases. That is what lets them be
defaulted with `= {}` in the header: a nested class's member initializers are
not parsed until the enclosing class is complete, so a default argument inside
the class cannot reach them - which is why bind() needed two overloads before.
One bind() now, with `options = {}`, and a server that can be constructed
without arguments at all. Callers, tests and the CLI take the defaults instead
of spelling out empty structs, and the java binding gains the matching no-arg
constructor while python defaults its config argument.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017sjMgQ5jJuzvE6ErSNuirb
andiwand
enabled auto-merge (squash)
July 27, 2026 15:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Generated with Claude Code
HttpServer::listen(host, port)takes the address, blocks, and returnsvoid— discarding theboolcpp-httplib hands it:So a failed bind is known inside the library and thrown away twice, and the caller is left with a server that never listened and no way to find out.
That is not hypothetical. OpenDocument.droid hit it in opendocument-app/OpenDocument.droid#536: both flavors hardcode port 29665,
connectedCheckruns them one after the other on one device, and the second app's bind failed against the first's TIME_WAIT sockets. No server, no complaint, and every document silently became chrome's error page — which read as "edit mode is broken" for three rounds of CI archaeology until aWebViewClient.onReceivedErrorfinally saidnet::ERR_CONNECTION_REFUSED. The app now probes the port with a throwaway Java socket before starting the server, which is a guess about how the native bind behaves, and racy. This removes the need for it.What changed
Configis empty,serve_fileandconfig()are gone, andlisten(host, port)becamebind()+listen().The split
ConfigandOptionssit at namespace scope, next toHtmlConfigwhich is spelled that way already, and the class keeps them as aliases (HttpServer::Configstill works). That is what lets both be defaulted with= {}: a nested class's member initializers are not parsed until the enclosing class is complete, so a default argument inside the class cannot reach them. Hence onebind()rather than two overloads, andHttpServer server;with no arguments at all.Three things follow from it:
ServerBindFailedcarries the host and portbindreturns what the OS gave, so no caller has to pick a free port and hope it stays free::listen(sock, backlog)inside the bind, so connections queue from the momentbind()returns, before the accept loop startsTwo states cpp-httplib handles silently, now errors
svr_sock_without closing it, leaking the first socket and holding its port until the process ended →ServerAlreadyBoundlisten_internal()setsret = true, findssvr_sock_ == INVALID_SOCKETso the accept loop never runs, and returns true. Aboolreturn would have reported success for the one case a caller most needs to hear about →ServerNotBoundThe second is why
listen()throws rather than returningbool.Config is empty, and the server no longer owns a cache
Configheld nothing butcache_path, which existed only for theserve_filemarked// TODO remove— and for aclear()that deleted the caller's translated files underneath them. Both are gone. The server hosts what it is given; what a service was translated into belongs to whoever translated it, andclear()now only drops the connected services.Callers translate for themselves, which is what
serve_filedid internally anyway:const HtmlService service = html::translate(file, my_cache_path, html_config, logger); server.connect_service(service, prefix);Configstays as an empty struct, so server-wide settings have somewhere to land later.Socket options belong to the bind
HttpServer::Optionsis passed tobind(), not held on the server, and it corrects a default:SO_REUSEADDRSO_REUSEPORTis undefinedSO_REUSEPORTOnly
SO_REUSEADDRlets a port still held by TIME_WAIT sockets be bound again — the failure above.SO_REUSEPORTdoes not help with that at all, and what it does do is let a second live server share the port and take a random share of the connections, which for a document server means requests answered by the wrong instance. It also only shares between sockets of the same uid, so it was never going to help two Android flavors either.Windows keeps cpp-httplib's defaults (thanks to the review bot for catching this). The two flags mean the opposite there:
SO_REUSEADDRlets a second live socket take the endpoint over, andSO_EXCLUSIVEADDRUSE— which the default sets and my first version dropped — is what keeps the port ours. Applying the POSIX mapping there would have handed the port away, which is precisely the hazardreuse_port: falseexists to avoid.Optionsis documented as POSIX only.Known limitation, documented in the header
stop()releases the socket through cpp-httplib'sServer::stop(), which only closes itif (is_running_).~Server()is= defaultand never closes it. So a server that was bound but never listened keeps its port until the process ends. Enforcing the pairing here would mean either owning a thread inside the library or patching upstream; for now the header says so plainly.Follow-up this opens
OpenDocument.droid can delete its
choosePort/bindableprobe and simply ask for port 0.Callers and verification
CLI binds before printing its urls, prints the port it got, and owns its cache directory. The jni and python bindings gain
bind()andOptions, loseserveFile/cachePath, and both test suites drop the free-port dances they needed to work around the old API.odr_test --gtest_filter='HttpServer.*'— 4 new tests, all pass (new file; there was no C++ coverage ofHttpServerbefore)HttpServerTest— 2 new tests pass locally,serveFileskipped for missing test datatest_http_server.py— 4 pass against a locally builtpyodr_core, including the rewrittenserve_filetest that now translates and connects for itselfcli/serverserves a real odt end to endscripts/formatandblack --checkcleanOne detail worth knowing for the tests:
localhostresolves to both::1and127.0.0.1, so a "bind a port already in use" test againstlocalhostquietly succeeds on the other address. The tests use literal127.0.0.1.