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:
@@ -418,13 +418,16 @@ void ConnSession::handle_join_channel(uint64_t req_id,
|
||||
res->set_channel_id(msg.channel_id());
|
||||
*res->mutable_audio() = ch->audio();
|
||||
|
||||
// Broadcast that this user changed channel.
|
||||
// Broadcast that this user changed channel — to everyone INCLUDING the mover.
|
||||
// The JoinChannelResult only acks the request; this UserEvent is the authoritative
|
||||
// state change every client (mover included) applies to its local model. Excluding
|
||||
// the mover here is what made it read a stale self-channel. See docs/protocol.md §6.
|
||||
if (auto updated_user = registry_->user_snapshot_user(uid)) {
|
||||
auto bcast = make_env();
|
||||
auto* ue = bcast.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = *updated_user;
|
||||
registry_->broadcast(bcast, session_id_);
|
||||
registry_->broadcast(bcast, /*exclude*/ 0);
|
||||
}
|
||||
}
|
||||
send_envelope(env);
|
||||
@@ -439,7 +442,7 @@ void ConnSession::handle_leave_channel() {
|
||||
auto* ue = bcast.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = *updated_user;
|
||||
registry_->broadcast(bcast, session_id_);
|
||||
registry_->broadcast(bcast, /*exclude*/ 0); // include the leaver — same as join
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +547,7 @@ void ConnSession::handle_stream_announce(uint64_t req_id,
|
||||
auto* ue = bcast.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = *updated;
|
||||
registry_->broadcast(bcast, session_id_);
|
||||
registry_->broadcast(bcast, /*exclude*/ 0); // include announcer; result only acks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +563,7 @@ void ConnSession::handle_stream_stop(const voicecat::v1::StreamStop& msg) {
|
||||
auto* ue = bcast.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = *updated;
|
||||
registry_->broadcast(bcast, session_id_);
|
||||
registry_->broadcast(bcast, /*exclude*/ 0); // include the stopper
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user