net: format socket errors with a thread-safe renderer (fixes #625) - #641
Conversation
Every throw site in `TcpSocket` built its message with `std::strerror`, which
is permitted to return a pointer to one static buffer shared by all callers,
and every one of them runs on whichever thread hit the error. This subsystem
spawns those threads itself: `SocketServer` runs an accept loop thread plus one
`clientLoop` thread per accepted connection (each driving `recvSome`/`sendAll`),
and `SocketBackend` runs an I/O thread and a handler thread. Two of them can be
inside a throw site at the same moment.
Replaced with a private `errnoMessage()` over `std::system_category().message()`,
which returns an owned `std::string` and carries the library's ordinary "shall
not introduce a data race" guarantee. Chosen over `strerror_r`, whose XSI and
GNU variants differ in return type and so need a build-time discriminator and a
caller-supplied buffer.
`TcpSocket::connect`'s `::gai_strerror` is deliberately left alone. It is a
different function rendering `EAI_*` resolver codes, which are not `errno`
values, so `std::system_category()` cannot describe them -- there is no drop-in
substitution, and clang-tidy's `concurrency-mt-unsafe` does not classify it as
unsafe (measured: it reports no finding on that line). Filed separately rather
than swept in.
The hand-written `NOLINTNEXTLINE(concurrency-mt-unsafe)` at the `tryAccept()`
site goes with it. Its reason ("as at every other throw site here") generalised
a suppression to six sites that never carried one, which is the shape #627 was
about.
Measured on this branch, clang-tidy 22.1.8, same invocation each time
(`clang-tidy -p <build> --checks='-*,concurrency-mt-unsafe'
--header-filter='include/morph/.*' tests/net/test_tcp_socket.cpp`):
a8511aa as-is 6 findings, exit 1 (the 7th suppressed)
a8511aa with the NOLINT gone 7 findings, exit 1
this commit 0 findings, exit 0
Not measured: the race itself. No interleaved or corrupted message was
observed; the defect is what the specification of `std::strerror` permits,
inferred from the code. The added test pins the message shape and the category
that renders it -- it was run against the pre-change header and passed there
too, so it is a standing guard, not evidence for this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner verification (landing sweep, 2026-09-20)The branch does what it says. Checked against the tree, not the report:
The ticket's count was mine and it was wrong. My triage on #625 said "eight call sites"; it is seven. On the load-bearing claim. It is a specification claim ( What I particularly want on the record, because it is the opposite of the usual failure here: the lane ran its own new test against the pre-change header, found it passed there too, and reported that the test is a standing guard rather than evidence for the fix. A test that passes in both worlds is exactly what AGENTS.md means by a control that measures nothing — the lane found that in its own work and published it instead of letting the green tick speak. Same for the near-miss it reported: it nearly filed an issue claiming Not merged this sweep: 7 pass, 23 pending. Left for the next sweep; pending PRs are not polled. 🤖 Generated with Claude Code |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Fixes #625.
TcpSocketformatted every socket error withstd::strerror, which ispermitted to return a pointer to one static buffer shared by all callers, and
every one of those call sites runs on whichever thread hit the error. The
subsystem spawns those threads itself, so two of them can be inside a throw
site at once.
The change
TcpSocket::errnoMessage(int)overstd::system_category().message(), used at all sevenstd::strerrorsites.It returns an owned
std::stringand carries the library's ordinary "shallnot introduce a data race" guarantee. Chosen over
strerror_r, whose XSI andGNU variants differ in return type and so need a build-time discriminator and
a caller-supplied buffer.
NOLINTNEXTLINE(concurrency-mt-unsafe)at thetryAccept()site is removed with its subject, per Four NOLINTNEXTLINE directives are wrapped onto two lines and suppress nothing; six findings leak past them #627's lesson.
docs/spec/security.md'smorph::netsection states the property, includingwhat was and was not measured.
The one claim the branch's safety rests on
That
std::error_category::messagemay be called concurrently, wherestd::strerrormay not. It is a specification claim, not a measurement:std::strerroris one of the functions the C and C++ standards permit to use ashared static buffer, and
error_category::messageis subject to[res.on.data.races] with no carve-out. If that reading is wrong, the change is
merely neutral rather than harmful — the message text is unchanged (measured
below) — but the issue would not be fixed.
Reachability — the ticket's open question, answered
#625 asked whether all the sites are reachable from more than one thread. They
are, and by construction rather than by accident:
SocketServer::acceptLoop()runs on its own thread and callstryAccept()(
socket_server.hpp:95,263).SocketServerspawns oneclientLoopthread per accepted connection(
socket_server.hpp:280), each drivingrecvSome()(:376) andsendAll()(
:414). N concurrent connections means N threads in those two sites.SocketBackendruns an I/O thread and a handler thread(
socket_backend.hpp:106-107), callingrecvSome()(:894) andsendAll()(
:642).listen()andconnect()are the weaker cases — they run on whatever threadconstructs the server or backend — but they share the buffer with everything
above, so a concurrent
recvSomefailure is enough.So the ticket is not closable as
invalidon its stated condition.A correction to the ticket
The triage counted "eight call sites"; the true count is seven.
grep -c "std::strerror"returns 8 because line 331 is the NOLINT comment,which contains the word:
Three in
listen(), one each inaccept(),tryAccept(),recvSome(),sendAll(). Nothing about the fix changes; the number in the issue does.::gai_strerror: explicitly out of scopeTcpSocket::connectrenders resolver failures with::gai_strerror. Leftalone, deliberately:
rcis anEAI_*code, not anerrnovalue, sostd::system_category().message(rc)would render a confidently wrong string.There is no drop-in substitution, and clang-tidy does not classify the function
as unsafe (measured: the same probe file flags
std::strerrorand not::gai_strerror). Its thread-safety is nonetheless unestablished by thisproject, so it is filed as #640 rather than swept in or left unrecorded.
Verification
Measured. clang-tidy 22.1.8 (the version CI pins), same invocation each
time, against a
-DMORPH_BUILD_NET=ON -DCMAKE_BUILD_TYPE=Debugclang compiledatabase:
a8511aa6as-isa8511aa6, NOLINT line deletedThe 7-finding run, verbatim, is the mutation that shows the check is not
vacuous — the check fails when the fix is absent:
Measured — the CI gate itself, reproduced locally.
clang-tidy-diff.pywith CI's own arguments over
git diff -U0 origin/master:Not vacuous either: with
errnoMessage's body mutated back tostd::string{std::strerror(err)}, the same harness exits 1 and names the line:Measured — build and tests.
-DMORPH_BUILD_NET=ON -DMORPH_BUILD_TESTS=ON,clang 22.1.8,
-Weverything+ strict on: builds clean,All tests passed (1112 assertions in 191 test cases).g++ 16.2.1 -std=c++23 -Wall -Wextra -fsyntax-onlyon the header: clean. clang-formatclean on both changed C++ files. Doxygen (
MORPH_BUILD_DOCUMENTATION=ON,WARN_AS_ERROR = FAIL_ON_WARNINGS): clean.check_spec_sync.sh,check_spec_citations.sh,check_catch_test_names.sh,check_nolint_directives.sh: all OK.Measured — the new test is not evidence for this change, and says so.
It was run against the pre-change header and passed there too:
It is a standing guard on the message shape. It does fail when the shape
changes — mutating the prefix to
bind failed:fails it — so it is not ano-op, just not a detector of this defect.
Not measured, and not claimed. The race itself. No interleaved or
corrupted message was ever observed. The defect is what the specification of
std::strerrorpermits, inferred from the code plus the thread inventoryabove. On glibc/Linux the two spellings render an
errnoto identical bytes(measured, immediately above), so no before/after behaviour difference is
observable on the only platform CI runs.
Review notes
Reasoning done inline rather than via
/code-review, per the lane's bounds.Three things I looked for and did not find a problem with:
errnoclobbering.errnoMessageis called in the same expression thatreads
errnoat three sites (listen'ssocket(),recvSome,sendAll).The read happens before the call in every case — the argument is evaluated
first — and the other four sites already cached
errnointoerrbeforeany intervening
::close(). Unchanged from before.<cstring>was in this header only forstd::strerror; removed.std::size_twas relying on it transitively, so<cstddef>replaces it, and<system_error>is added. GCC and clang bothcompile the header standalone.
system_category().message()allocates,where
strerrordid not — but the surrounding code already builds astd::stringand throws astd::runtime_error, so the throw path allocatedeither way.
Files
Touches only
include/morph/net/detail/tcp_socket.hpp,tests/net/test_tcp_socket.cppanddocs/spec/security.md, none of which isheld by #630/#635/#638/#639/#561.
docs/spec/core/backend.mdwould have beenthe other home for the spec note (
check_spec_sync.shaccepts either fornet), but it is held by #639, so the note went tosecurity.md, where themorph::nettransport's posture already lives.scripts/branch_partial_allowlist.jsoncitessocket_server.hppandsocket_backend.hpplines, not this header — no line it cites moves.🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW