Summary
include/morph/net/detail/tcp_socket.hpp builds every one of its error messages
with std::strerror(errno). std::strerror is not required to be thread-safe —
glibc returns a pointer into a static buffer for unknown error numbers and is
explicitly documented as not MT-safe — and this header is used from a design
that is multi-threaded by construction:
net/socket_server.hpp:95 spawns an accept thread, and :280 spawns one
thread per client connection;
net/socket_backend.hpp:106-107 spawns an I/O thread and a handler thread.
Two of those threads hitting a socket error at the same time can read a buffer
the other is writing. The damage is confined to the message text of a
std::runtime_error — no data is corrupted — so this is a wrong-diagnostic bug,
not a crash. It is also trivially fixable.
The seven sites
$ grep -rn "strerror" include src | grep -v strerror_r
include/morph/net/detail/tcp_socket.hpp:111: throw std::runtime_error("TcpSocket::connect: getaddrinfo failed for " + host + ": " + ::gai_strerror(rc));
include/morph/net/detail/tcp_socket.hpp:195: throw std::runtime_error(std::string{"TcpSocket::listen: socket() failed: "} + std::strerror(errno));
include/morph/net/detail/tcp_socket.hpp:206: throw std::runtime_error(std::string{"TcpSocket::listen: bind() failed: "} + std::strerror(err));
include/morph/net/detail/tcp_socket.hpp:211: throw std::runtime_error(std::string{"TcpSocket::listen: listen() failed: "} + std::strerror(err));
include/morph/net/detail/tcp_socket.hpp:276: throw std::runtime_error(std::string{"TcpSocket::accept: "} + std::strerror(errno));
include/morph/net/detail/tcp_socket.hpp:331: // NOLINTNEXTLINE(concurrency-mt-unsafe) — std::strerror, as at every other throw site here
include/morph/net/detail/tcp_socket.hpp:332: throw std::runtime_error(std::string{"TcpSocket::tryAccept: "} + std::strerror(err));
include/morph/net/detail/tcp_socket.hpp:353: throw std::runtime_error(std::string{"TcpSocket::recvSome: "} + std::strerror(errno));
include/morph/net/detail/tcp_socket.hpp:371: throw std::runtime_error(std::string{"TcpSocket::sendAll: "} + std::strerror(errno));
:111 is ::gai_strerror, which is a different function and is thread-safe on
glibc; it is listed only for completeness. The other seven are std::strerror.
Of the seven, sendAll and recvSome (:371, :353) are the ones actually
reached concurrently in normal operation: socket_server.hpp's per-client
threads and socket_backend.hpp's I/O thread each drive their own socket, and a
transport dropping several connections at once is exactly when two of them throw
together.
The existing suppression is the pattern #580 warns about
Line 331 already carries:
// NOLINTNEXTLINE(concurrency-mt-unsafe) — std::strerror, as at every other throw site here
The stated reason is consistency with the other six sites, not safety. It
suppresses one instance of a check while leaving six identical ones live, and it
does not address the thing the check is reporting. #580 names this shape
directly — "the cheapest way out is a NOLINT, which is how a check set
decays" — and include/morph/render/locale_format.hpp:181 shows what the
alternative looks like in this tree (a paragraph explaining why the check is
wrong here).
Whatever is decided about the code, that comment should either become a real
argument or go away with the fix.
Verification status
Reproduced (the findings). On a020e69c, clang-tidy 22.1.8, .clang-tidy
unmodified, from a full-database sweep of all 695 translation units with
MORPH_BUILD_NET=ON:
include/morph/net/detail/tcp_socket.hpp:195:92: error: function is not thread safe [concurrency-mt-unsafe]
include/morph/net/detail/tcp_socket.hpp:206:90: error: function is not thread safe [concurrency-mt-unsafe]
include/morph/net/detail/tcp_socket.hpp:211:92: error: function is not thread safe [concurrency-mt-unsafe]
include/morph/net/detail/tcp_socket.hpp:276:75: error: function is not thread safe [concurrency-mt-unsafe]
include/morph/net/detail/tcp_socket.hpp:353:81: error: function is not thread safe [concurrency-mt-unsafe]
include/morph/net/detail/tcp_socket.hpp:371:80: error: function is not thread safe [concurrency-mt-unsafe]
Six, not seven, because :332 is suppressed. These are the only
concurrency-mt-unsafe findings anywhere under include/morph/.
Inferred from reading the code, not observed. That two of these sites can
execute concurrently: established by reading socket_server.hpp:95/280 and
socket_backend.hpp:106-107, which create the threads, and by the fact that
each thread owns its own TcpSocket. I did not construct a test that makes
two threads throw at the same moment.
Not verified.
- No ThreadSanitizer report. I did not run the TSan preset against
morph::net, and I would not expect it to fire on its own: the race needs two
concurrent failing syscalls, which the existing tests do not arrange. This
is latent, which is why static analysis found it and the dynamic gates have
not.
- No demonstration of a torn message. I have not produced a wrong error
string; the claim rests on strerror's documented contract, not on
observation.
- Not checked on macOS/Windows.
morph::net is POSIX-only
(MORPH_BUILD_NET is ignored on Windows), so glibc and Apple libc are the
relevant implementations; I checked neither's source, only the documented
guarantee.
Suggested fix (not applied)
std::generic_category().message(errno) is thread-safe and returns a
std::string, so it drops straight into every one of these expressions with no
buffer management:
throw std::runtime_error(std::string{"TcpSocket::sendAll: "} +
std::generic_category().message(errno));
Seven sites, one #include <system_error>, and the NOLINTNEXTLINE at :331
is deleted rather than replicated. strerror_r is the C alternative but has two
incompatible signatures across libc's and would need a wrapper.
I deliberately did not apply this: it is outside the remit of the measurement
lane that found it, and AGENTS.md says to file rather than fold.
What would change the verdict
Close this when the seven sites no longer call std::strerror and the
suppression at :331 is gone, so clang-tidy reports zero
concurrency-mt-unsafe under include/morph/ with the check enabled and no
NOLINT holding it back.
Downgrade or close as won't-fix if someone establishes that std::strerror is
in fact MT-safe on every libc morph supports — in which case the six findings
want one reasoned suppression saying so, not seven copies of the current
comment.
Related
Found while running the full-tree clang-tidy census for #580; it is one of that
census's 596 findings. Filed separately because it is a claim about shipped
runtime behaviour, not about the gate's scope, and because its fix does not wait
on #580's policy decision.
Summary
include/morph/net/detail/tcp_socket.hppbuilds every one of its error messageswith
std::strerror(errno).std::strerroris not required to be thread-safe —glibc returns a pointer into a static buffer for unknown error numbers and is
explicitly documented as not MT-safe — and this header is used from a design
that is multi-threaded by construction:
net/socket_server.hpp:95spawns an accept thread, and:280spawns onethread per client connection;
net/socket_backend.hpp:106-107spawns an I/O thread and a handler thread.Two of those threads hitting a socket error at the same time can read a buffer
the other is writing. The damage is confined to the message text of a
std::runtime_error— no data is corrupted — so this is a wrong-diagnostic bug,not a crash. It is also trivially fixable.
The seven sites
:111is::gai_strerror, which is a different function and is thread-safe onglibc; it is listed only for completeness. The other seven are
std::strerror.Of the seven,
sendAllandrecvSome(:371,:353) are the ones actuallyreached concurrently in normal operation:
socket_server.hpp's per-clientthreads and
socket_backend.hpp's I/O thread each drive their own socket, and atransport dropping several connections at once is exactly when two of them throw
together.
The existing suppression is the pattern #580 warns about
Line 331 already carries:
// NOLINTNEXTLINE(concurrency-mt-unsafe) — std::strerror, as at every other throw site hereThe stated reason is consistency with the other six sites, not safety. It
suppresses one instance of a check while leaving six identical ones live, and it
does not address the thing the check is reporting. #580 names this shape
directly — "the cheapest way out is a
NOLINT, which is how a check setdecays" — and
include/morph/render/locale_format.hpp:181shows what thealternative looks like in this tree (a paragraph explaining why the check is
wrong here).
Whatever is decided about the code, that comment should either become a real
argument or go away with the fix.
Verification status
Reproduced (the findings). On
a020e69c, clang-tidy 22.1.8,.clang-tidyunmodified, from a full-database sweep of all 695 translation units with
MORPH_BUILD_NET=ON:Six, not seven, because
:332is suppressed. These are the onlyconcurrency-mt-unsafefindings anywhere underinclude/morph/.Inferred from reading the code, not observed. That two of these sites can
execute concurrently: established by reading
socket_server.hpp:95/280andsocket_backend.hpp:106-107, which create the threads, and by the fact thateach thread owns its own
TcpSocket. I did not construct a test that makestwo threads throw at the same moment.
Not verified.
morph::net, and I would not expect it to fire on its own: the race needs twoconcurrent failing syscalls, which the existing tests do not arrange. This
is latent, which is why static analysis found it and the dynamic gates have
not.
string; the claim rests on
strerror's documented contract, not onobservation.
morph::netis POSIX-only(
MORPH_BUILD_NETis ignored on Windows), so glibc and Apple libc are therelevant implementations; I checked neither's source, only the documented
guarantee.
Suggested fix (not applied)
std::generic_category().message(errno)is thread-safe and returns astd::string, so it drops straight into every one of these expressions with nobuffer management:
Seven sites, one
#include <system_error>, and theNOLINTNEXTLINEat:331is deleted rather than replicated.
strerror_ris the C alternative but has twoincompatible signatures across libc's and would need a wrapper.
I deliberately did not apply this: it is outside the remit of the measurement
lane that found it, and AGENTS.md says to file rather than fold.
What would change the verdict
Close this when the seven sites no longer call
std::strerrorand thesuppression at
:331is gone, soclang-tidyreports zeroconcurrency-mt-unsafeunderinclude/morph/with the check enabled and noNOLINTholding it back.Downgrade or close as won't-fix if someone establishes that
std::strerrorisin fact MT-safe on every libc morph supports — in which case the six findings
want one reasoned suppression saying so, not seven copies of the current
comment.
Related
Found while running the full-tree clang-tidy census for #580; it is one of that
census's 596 findings. Filed separately because it is a claim about shipped
runtime behaviour, not about the gate's scope, and because its fix does not wait
on #580's policy decision.