The command socket stayed bound until ~StreamBase(), which runs only after
the stream thread has finished. A zms that was shutting down but still busy
in the stream thread (blocked writing to a client that had gone away, for
instance) kept accepting commands it would never read. web/ajax/stream.php
then waited out its whole MSG_TIMEOUT on each one and logged "Timed out
waiting for msg ... after waiting 5000 milliseconds", where a missing socket
would have been reported as no_socket at once.
Call closeComms() when checkCommandQueue() leaves its loop. sd is only used
from the command thread, and closeComms() clears sd and lock_fd, so the
destructor's later call does nothing and cannot remove a socket bound by a
successor zms on the same connkey.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lock_fd was initialised to 0, but every other use in the class treats a
negative value as "no lock held": openComms() sets it to -1 when open() or
flock() fails, and closeComms() guards on `lock_fd >= 0` before closing.
The constructor's 0 passes that guard, so a StreamBase destructed without
openComms() having succeeded calls close(0) and closes stdin. Reaching it only
takes connkey > 0 plus a runStream() that returns before openComms(), and
MonitorStream::runStream() has two such returns: the STREAM_SINGLE branch and
the !monitor branch. mode=single URLs still carry a connkey, so both are
reachable in normal operation.
zms exits shortly afterwards and does little in between, so the practical
impact today is small. It is still closing a descriptor the class does not own,
and once fd 0 is free the next open() in the process silently lands on it.
Add a regression test covering destruction with and without a connkey. It saves
and restores fd 0 so a failure can't cascade into the rest of the suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>