DebuggerLogListener keeps a ring of the last BUFFER_SIZE messages. read_ and
count_ count messages ever read/written, while messages_ is indexed modulo
BUFFER_SIZE - but the non-overflow path used read_ directly as the index. Once
a session had logged BUFFER_SIZE messages that index was past the end of the
array, so the first copy loop (bounded by BUFFER_SIZE) ran zero times and the
second one handed back messages_[0..readCount-1] instead: real log messages,
just the wrong ones, and every later poll stayed the same distance out of step.
It looks like the tail of the log going missing rather than being wrong, which
is a bad way to find out. A log-only breakpoint in a hot loop reaches 1024
messages in seconds, and then "the last thing logged before the breakpoint hit"
- exactly what such a breakpoint is for - names an event thousands of messages
old. Confirmed against a case with an independently known answer: a log-only
breakpoint recording a register in a loop that runs ~20k times now ends with
the value that register actually held at the final hit, where before it ended
several hundred iterations short of it.
The overflow path was already correct - it starts from nextMessage_, which is
an index - so only the one line changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The log manager had a single external-callback slot, but the WebSocket
debugger registers one per *connection* - LogBroadcaster is a local in the
per-connection handler. So with two clients attached (the bundled JS debugger
in a browser and Tools/wsdbg, say) the second one to connect silently took the
log stream away from the first, and then whichever disconnected first cleared
the slot and stopped delivery to the other as well. A one-shot wsdbg command is
enough to do it: connect, take the stream, exit, and the long-lived listener
that was watching the log goes quiet with nothing to say why.
Make it a list with add/remove by handle. The dispatch loop holds the lock
across the callbacks so a listener can't be freed while one is running - which
is what lets LogBroadcaster delete its listener straight after removing it.
Enabling and disabling LogOutput::ExternalCallback belongs to the list now, and
disabling only happens when the last callback goes away.
libretro registers one of these too, and never removes it; it just moves to the
new call. It can't actually collide with the debugger - the libretro build
doesn't compile Core/Debugger/WebSocket at all - but there's no reason for it
to keep using an API that only has room for one caller.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DebuggerLogListener buffers up to 1024 log messages between polls of the
WebSocket event loop (up to 1000Hz under high activity, 60Hz otherwise -
see WebSocket.cpp). A source that logs faster than that - a log-only
breakpoint hit thousands of times in a tight loop is a real example, not
hypothetical, see docs/VSHBootInvestigation.md's Attempt 22/24 - can wrap
the ring buffer before GetMessages() ever reads the oldest entries,
silently losing them. From the client's side this was indistinguishable
from the breakpoint just not firing at all, which cost real debugging time
this session tracking down a red herring before finding the real
mechanism.
GetMessages() already detected the overflow case internally (the
`read_ + BUFFER_SIZE < count_` branch) to avoid returning garbage, but
never reported how many messages were actually lost. Now synthesizes a
warning LogMessage ("N log message(s) dropped - client polling too slow
for this volume") and prepends it to the batch whenever this happens, so
a real gap is visibly distinguishable from "this just never got logged."
Verified via UnitTest.exe all (49/49) and a live PPSSPPHeadless + wsdbg
session confirming normal (non-overflow) log relay still works
end-to-end.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9