fix(protocol): deliver self-initiated state changes to the actor too

A connected Windows client would randomly snap from its joined channel
back to Lobby. Root cause was a state-sync inconsistency, not a drop:
the server delivered self-initiated state changes (channel join/leave,
stream announce/stop) only as a private *Result to the actor and
broadcast the authoritative UserEvent::UPDATED to everyone else. The
core never applied the result to its SessionModel, so vc_list_users()
kept self in the old channel; the Windows HandleUserUpdated rebuilds
_currentChannelId from vc_list_users() on any user's UPDATED event, so
the next unrelated event surfaced the stale self-channel.

Fix, per the response-vs-broadcast contract now documented in
docs/protocol.md §6: the *Result is pure ack/correlation/actor-private
payload; the resulting state change is broadcast to every client
INCLUDING the actor, and clients apply it to their local model rather
than re-deriving own state from a *Result.

- server: join/leave/stream announce+stop broadcast with exclude=0
- server: text fan-out includes the sender (channel + private echo)
- core: response handlers no longer mutate session_model_
- windows: drop optimistic text echo; render own message via the relay
- docs/protocol.md §6: document the response-vs-broadcast contract

Registry-level admin broadcasts (move/mute/kick/channel CRUD) already
used exclude=0 and were correct. ctest build/m1-dev 18/18 green;
VoiceCat.App builds 0 warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:48:50 +02:00
parent 9b321d0d4f
commit 118ca5129f
6 changed files with 68 additions and 28 deletions

View File

@@ -163,24 +163,27 @@ std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets(
std::shared_lock lk(mu_);
std::vector<std::shared_ptr<ConnSession>> targets;
auto add_session = [&](uint64_t sid) {
auto sit = sessions_.find(sid);
if (sit == sessions_.end()) return;
if (auto sess = sit->second.lock()) targets.push_back(sess);
};
if (scope == voicecat::v1::TEXT_CHANNEL) {
// Find channel_id of the target, then all users in that channel
// Fan out to everyone in the target channel, INCLUDING the sender, so the sender's
// own client renders the message through the same authoritative relay everyone else
// gets (no optimistic local echo). See docs/protocol.md §6.
for (auto& [uid, entry] : users_) {
if (entry.proto.channel_id() != target_id) continue;
if (entry.session_id == sender_session_id) continue;
auto sit = sessions_.find(entry.session_id);
if (sit == sessions_.end()) continue;
if (auto sess = sit->second.lock()) targets.push_back(sess);
add_session(entry.session_id);
}
} else if (scope == voicecat::v1::TEXT_PRIVATE) {
// target_id is user_id
// target_id is the recipient user_id: deliver to the recipient and echo to the
// sender (skip the echo if they messaged themselves, to avoid a duplicate).
auto user_it = users_.find(target_id);
if (user_it != users_.end()) {
auto sit = sessions_.find(user_it->second.session_id);
if (sit != sessions_.end()) {
if (auto sess = sit->second.lock()) targets.push_back(sess);
}
}
if (user_it != users_.end()) add_session(user_it->second.session_id);
if (user_it == users_.end() || user_it->second.session_id != sender_session_id)
add_session(sender_session_id);
}
return targets;
}