SubscriptionManager::processCommand()'s "invite" branch looked up its
target via getSessionByAlias(&invitee), where invitee is a std::string
local to that function call. getSessionByAlias(void*) compares by
pointer identity against each session's own alias - the address of a
brand-new local variable can never equal any of those, so the lookup
was guaranteed to return NULL every time, and the result was
dereferenced unconditionally right after. Any client sending a bare
"invite:" command crashed the entire process, not just its own
connection - same class of bug as the two GuildManager crashes fixed
earlier this session, just found afterward while documenting this
file's own mechanics.
This generic ServerCore layer has no name-based session lookup
available (that only exists as a Zephora-specific addition on top of
TCPServer), so this stops the crash with a null check rather than
attempting a real name-based fix, which would need a larger change.
Also includes this file's pending brace-style reformat (K&R -> Allman)
that was sitting uncommitted before this fix - bundled in rather than
left stranded, no functional change from that part.
subscriptions[id] via operator[] default-constructed (NULL) and
inserted an entry for any unregistered id, and the very next line
dereferenced it unconditionally - any client referencing a
subscription id that was never created (e.g. Zephora client sending
subscribe:invite for a channel nobody ever Publish()d) segfaulted the
entire process, not just that connection. Use find() and return
gracefully instead.
shutdown() only sets shutDown=true and expects the fifo to drain
normally before eventReceived()'s keepAlive check
(!shutDown || (fifo.size() > 0 && !readHangup)) lets the connection
close - correct for a graceful QUIT/LOGOUT reply that just needs a few
more successful writes to flush, but exactly backwards for the give-up
path: the whole reason that path was reached is that the front of the
fifo can never be sent. Leaving it queued meant keepAlive stayed true
forever, resetSocket() kept re-arming EPOLLWRNORM because fifo wasn't
empty, the very next write attempt hit the identical stall
immediately, and shutdown() got called again - forever, at whatever
rate the kernel could fire EPOLLWRNORM.
Confirmed live on a real internet-facing server (Zephora's game
server, an ordinary port-scanner connection was enough to trigger it):
one socket logged "consecutive writes made zero progress - giving up
and shutting down" 2.4 million+ times in under 4 milliseconds,
pegging a worker thread at 100% CPU indefinitely. The give-up path now
clears the undeliverable fifo entry before calling shutdown(), so
keepAlive correctly evaluates to just !shutDown (now false) on the
next cycle and the connection actually tears down.
CommandList::processRequest() upper-cases the incoming keyword before
looking it up, but add() stored whatever case the caller passed in
verbatim - any registration not already uppercase could then never be
matched by any input at all, since upper-casing a non-uppercase string
cannot produce a match against a non-uppercase key. This was invisible
on BARANTMail/APIServer (their own commands - SMTP, POP3/IMAP, the
admin console - all happen to already be registered uppercase) but
confirmed, via a full Zephora game client/server protocol comparison,
to make literally every Zephora game command unreachable, including
the base TCPServer's own publish/subscribe/event/etc. registrations
that the whole subscription layer depends on. Folding at add() time
keeps registration and lookup permanently in sync regardless of
caller case; already-uppercase callers are unaffected (idempotent).
The merge commit (3609ed6) used git add -A to handle the large number
of deletes/renames from conflict resolution, which also picked up 22
untracked *.pre-* files - local safety-copy backups made before
editing tracked files, never meant to be committed. Untracked (kept on
disk) rather than deleted, and added *.pre-* to .gitignore so this
does not recur.
tls and master diverged from a July 2024 fork point and never
reconciled - tls carries ~19 months of real socket/TLS stability
fixes (uninitialized tlsInfo, TCPServer::connectionRequest()
unconditionally initializing TLS, the WANT_READ write busy-spin, the
console authentication fix from this session), while master
independently picked up a Subscription/SubscriptionManager rework
(MString-based ownership tracking: owner/ownership/selection/time
fields and a working subscriptionOwner() method) that tls never had.
Every file both branches touched was resolved by keeping tls's side
entirely (Command.h, Socket.cpp, TCPServer.h, TCPSession.cpp/h,
TCPSocket.h were pure reformatting on master's side, or an
alternate/superseded fix for a bug tls already fixed a different way -
e.g. master's TCPSession uuid/alias handling vs tls's existing
uuid_unparse-based fix), with two deliberate exceptions:
- TCPServer.cpp's constructor was missing the
commands.add(subscriptions, "publish"/"subscribe"/"catalog"/...)
wiring master had - without it, SubscriptionManager existed but was
completely unreachable through the normal command dispatch. Restored
on top of tls's TLS-aware constructor.
- SubscriptionManager.cpp's "invite" command handler had a real
incompatibility: master's version assigned into an `invitee` member
typed as coreutils::MString via a stray `(coreutils::MString *)`
cast that doesn't match the member's actual (non-pointer) type, and
called session.server.getSessionByAlias() against a TCPServer
reference - tls made TCPSession::server a pointer (part of removing
the separate TCPSession2 class), so that call site no longer
compiles as written. Took tls's cleaner local-variable version
instead (functionally identical - looks up a session by alias and
forwards an invite message) and removed the now-unused `invitee`
member from SubscriptionManager.h.
One additional compile fix needed after resolving conflicts:
Subscription::subscriptionOwners is an MString (master's ownership
design), but TCPSession::alias is void* (tls's identity mechanism,
unchanged and depended on elsewhere) - MString has an implicit
constructor from void* already used successfully elsewhere in this
same file (the "publish" handler's Subscription constructor call),
but operator== doesn't accept the same implicit conversion, so
removeSessionSubscriptions()'s ownership check needed an explicit
coreutils::MString(session.alias) construction to compile.
Also drops output/main (a stray checked-in compiled binary, already
removed on tls) and the doxygen docs/html output (already removed on
tls, was carried on master since the fork).
Verified: full ServerCore rebuild (./compile) succeeds with zero
errors or warnings beyond the one compile fix documented above.
ConsoleSession's login flow never actually checked anything: the
entered username was discarded and the password was never compared,
so any input (including blank) reached the command prompt. Confirmed
live via an unauthenticated login against BARANTMail's admin console
on port 1027, which was reachable externally. ConsoleServer now takes
an optional credentialChecker callback consulted by the WAIT_PASSWORD
state; omitting it preserves the old accept-anything behavior for any
other use of this class.
Separately, the console never prompted until the client's first line
arrived - that line was silently discarded just to kick the state
machine off, shifting every subsequent line one slot early. Harmless
under the old accept-anything login, but it broke real credential
checking outright (the typed password was compared against the typed
username, etc). ConsoleSession::onConnected() now starts the state
machine immediately on connect instead.
CommandList::processCommand() was a no-op stub - the intended
"typing help lists the registered commands" body was commented out,
and even referenced a Command::getName() that was never added to the
base class. The map's own key from add() already is the registered
name, so this just walks it directly.
A TLS sendData() failing with SSL_ERROR_WANT_READ (mid-handshake
application data, or a post-handshake session ticket exchange) has
nothing to do with write-readiness, but resetSocket() kept re-arming
EPOLLWRNORM anyway - an already-writable TCP socket fires that
immediately, so the retry loop spun the CPU as fast as it could
until the connection was eventually dropped, without ever reading
the peer's first command. Confirmed via a 15GB+ debug log from one
stuck connection. sendNeedsRead() lets Socket now tell the
difference and wait for a real EPOLLIN instead of busy-retrying.
Thread/EPoll lifecycle:
- Thread::~Thread() now actually joins/frees its worker thread (was
empty - the OS thread kept running against already-freed EPoll/Socket
objects on any early teardown, a confirmed "double free" crash)
- EPoll::~EPoll() calls stop() if never explicitly stopped, for the
same reason
- epoll_wait() now uses a 1s timeout instead of blocking forever, so a
worker thread can actually notice isStopping() and let itself be
joined
- EPoll::processCommand() iterated threads by value, silently
double-freeing the real vector's Thread the moment each copy went
out of scope
- ThreadScope gained onThreadStart()/onThreadEnd() hooks (actually
wired up now - the constructor that was supposed to store the scope
discarded it) for per-thread C-library init (e.g. MySQL client)
Socket/TCPSocket/TLS:
- Socket::writeSocket(): give up and shut down a connection after 50
consecutive zero-progress sends instead of retrying forever -
SSL_ERROR_WANT_READ on a write needs a READ to make progress, but
resetSocket() re-arms EPOLLWRNORM regardless, which an
already-writable TCP socket satisfies immediately - a real stuck
connection generated 55GB, then 12.9GB of debug log this way before
being caught
- Socket::writeSocket() now honors sendData()'s actual return value
(previously discarded, entry popped unconditionally after one call) -
a large response could get silently truncated past what fit in one
write()/SSL_write() call
- TLS::registerSocket()/sendData()/receiveData(): `initialized` now
only goes true once SSL_set_fd() has actually succeeded, and
sendData()/receiveData() check it first - fixes a real SIGSEGV where
a new session's own queued greeting could reach SSL_write() on a
different epoll thread before this session's handshake setup had run
- TCPServer::connectionRequest() now performs the TLS handshake ON THE
ACCEPTED SESSION, not the listening server itself (was building SSL
state on the wrong object, leaving every session's own `ssl`
uninitialized)
- TCPServer::receiveData() always uses the plain (non-TLS) read path -
the listening socket is never itself a TLS peer
- TCPSocket's block-mode literal reading used an unset `blockLength`
field instead of `blockSize`, and could busy-spin forever on a
multi-packet literal
- TCPSocket line scanning replaced an unbounded strcspn() (assumes a
NUL-terminated buffer; read past a non-NUL-terminated receive buffer
into adjacent heap memory) with a bounds-checked memchr() scan
Protocol/session handling:
- CommandList::processRequest() now case-folds the command keyword
before lookup - SMTP/POP3/IMAP commands are spec-required to be
case-insensitive; a client sending "Mail From:" instead of
"MAIL FROM:" previously failed to dispatch at all
- TCPSession::protocol() no longer drops blank lines while a handler
has grabbed input (e.g. __SMTP_DATA reading a message body, an IMAP
APPEND literal) - those handlers depend on seeing blank lines as
real content, not commands; this silently corrupted any message body
containing a blank line
- TCPSession's uuid alias fixed: streaming a raw uuid_t via operator<<
triggered libstdc++'s unsigned-char* overload (strlen() on 16 binary
bytes, a real stack-buffer-overflow read), and `alias` pointed at a
stack-local that was already destroyed by the time the constructor
returned
- INotify::processEvent() only treats event->name as a NUL-terminated
string when event->len > 0 (per inotify(7)) instead of unconditional
strlen() on a field the kernel doesn't guarantee is even present -
a confirmed heap-buffer-overflow read that had been misdiagnosed as
a MySQL thread-safety issue
TCPSocket's SSL_CTX*-taking constructor - the one every accepted
TCPSession actually goes through (TCPSession(EPoll&, TCPServer*, ...)
passes server->ctx into it) - never initialized the `tlsInfo` member,
and TCPSocket.h gave it no default either. Every session's `tlsInfo`
was uninitialized garbage.
receiveData()/sendData() key their TLS-vs-plain dispatch on
`if(tlsInfo)`. Since that garbage value is non-zero often enough on a
real stack, sessions on a plain, non-TLS server were intermittently
routed through TLS::receiveData()/sendData() (SSL_read()/SSL_write())
against an also-uninitialized SSL*, segfaulting inside libssl. Confirmed
via dmesg: repeated "general protection fault ... in libssl.so.3",
signal 11, non-deterministic timing consistent with depending on
whatever garbage happened to be on the stack for a given session.
Fixed by dispatching on `ctx` instead of `tlsInfo`. `ctx` (TLS.h) now
defaults to NULL (previously also uninitialized) and is the value that
actually gets set for real in TLS::createContext() and correctly
inherited by every session via TLS(SSL_CTX*) - unlike `tlsInfo`, which
is only ever set on the listening TCPServer's own constructor and never
propagated to sessions at all. For the previously-working TLS-enabled
case this is a no-op: ctx and tlsInfo were always both set (or both
unset) together on the listening server, so the observable behavior
there is unchanged - only the previously-undefined session-level
behavior is fixed.
Verified live: restarted the APIServer test instance (a plain, non-TLS
TCPServer - the exact configuration that was crashing), ran 100
sequential requests with no failures, and confirmed no new libssl
segfaults in dmesg since the fix (three separate pre-fix crash
incidents are all still visible in the dmesg history for comparison).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
connectionRequest() had a literal "if(true) { registerSocket(...);
acceptSocket(); }" guarding the TLS handshake setup for every accepted
connection, regardless of whether this TCPServer was ever given a
TLSInfo. TLS::createContext() only sets `ctx` when tlsInfo is non-NULL
(correctly guarded there) - so for any plain, non-TLS TCPServer, `ctx`
is left uninitialized, and this unconditional call reached
TLS::registerSocket() -> SSL_new(ctx) with garbage, segfaulting the
whole process on the very first connection.
Found while building APIServer (a new non-TLS HTTP server on top of
TCPServer) - confirmed via gdb (SIGSEGV inside SSL_new, called from
TLS::registerSocket, called from this exact line) that this affects ANY
non-TLS ServerCore-based server's first accepted connection, not
anything specific to the new code being tested against it.
Fixed by checking `tlsInfo` (already a TCPSocket-protected member this
class inherits) instead of the unconditional `true` - matches the guard
TLS::createContext() already uses correctly for the same distinction.
Verified live: before the fix, a non-TLS ApiServer segfaulted on the
first curl request every time; after, GET/HEAD/POST requests across
several real routes complete correctly and the server stays up for
repeated requests, including one that deliberately throws (confirming
the crash is really gone, not just less frequent).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit picked up CRLF line endings from a Windows-side
working copy used during development. Converting to plain LF to match
the rest of this Linux-native repo - content unchanged, this is purely
a line-ending normalization (932 insertions/932 deletions, 1:1).
TCPSocket inherits from both Socket and TLS, both declaring receiveData()
as unrelated sibling methods - only Socket::receiveData was virtual, so
TLS::receiveData (SSL_read) was never reached regardless of TLS config,
and even when reached its result was never forwarded to onDataReceived
(that call was commented out). Added an explicit TCPSocket::receiveData
override dispatching to TLS::receiveData or Socket::receiveData based on
tlsInfo, and completed TLS::receiveData to actually return/forward the
decrypted bytes.
Write path did not exist at all. Added Socket::sendData() as a new
virtual hook mirroring receiveData, routed Socket::writeSocket() through
it instead of a raw ::write(), added TLS::sendData() (SSL_write, same
error-handling style as the existing TLS methods), and TCPSocket::sendData()
dispatching the same way the read side does.
Also fixed: a queued write in writeSocket() was being popped off the fifo
as if sent even when shutDown was true and nothing was actually written;
removed a dead empty if(tlsInfo){} stub in onDataReceived, no longer
needed now dispatch happens one level up; replaced leftover debug
std::cout lines with proper Log() calls matching the rest of the file.
Verified via full local compile: all 20 .cpp files build clean, link
into libServerCore.a, and nm confirms the new/fixed symbols are correctly
defined in the right object files.