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:
@@ -320,8 +320,9 @@ public partial class MainForm : Form
|
||||
if (ev.Result == VcResult.Ok)
|
||||
{
|
||||
_currentChannelId = ev.ChannelId;
|
||||
// Server doesn't echo UserJoined/UserUpdated back to the mover — patch our
|
||||
// own entry in _users so RefreshUserList shows us in the new channel.
|
||||
// The authoritative UserEvent::UPDATED broadcast also reflects this move, but it
|
||||
// may arrive after this result — patch our own entry now for instant, flicker-free
|
||||
// feedback. The later UPDATED is idempotent (sets the same channel).
|
||||
if (_users.TryGetValue(_selfUserId, out var self))
|
||||
_users[_selfUserId] = self with { ChannelId = ev.ChannelId };
|
||||
RefreshChannelTree();
|
||||
@@ -351,7 +352,12 @@ public partial class MainForm : Form
|
||||
.LocalDateTime.ToString("HH:mm")
|
||||
: DateTime.Now.ToString("HH:mm");
|
||||
string sender = GetNickname(ev.UserId);
|
||||
string prefix = ev.TextScope == VcTextScope.Private ? "(private) " : "";
|
||||
// For a private message, ev.ChannelId carries target_id (the recipient user_id).
|
||||
// When the server relays our own private message back to us, label it with the
|
||||
// recipient; an incoming private message just shows "(private)".
|
||||
string prefix = ev.TextScope == VcTextScope.Private
|
||||
? (ev.UserId == _selfUserId ? $"(private to {GetNickname(ev.ChannelId)}) " : "(private) ")
|
||||
: "";
|
||||
rtbChat.AppendText($"[{time}] {prefix}{sender}: {ev.Text ?? ""}\n");
|
||||
rtbChat.ScrollToCaret();
|
||||
if (ev.TextScope == VcTextScope.Private && ev.UserId != _selfUserId)
|
||||
@@ -882,14 +888,9 @@ public partial class MainForm : Form
|
||||
|
||||
_client.SendText(scope, targetId, msg);
|
||||
txtCompose.Clear();
|
||||
|
||||
// Server excludes sender from channel fan-out — echo our own message locally.
|
||||
string time = DateTime.Now.ToString("HH:mm");
|
||||
string prefix = scope == VcTextScope.Private
|
||||
? $"(private to {GetNickname(targetId)}) "
|
||||
: "";
|
||||
rtbChat.AppendText($"[{time}] {prefix}{_nickname}: {msg}\n");
|
||||
rtbChat.ScrollToCaret();
|
||||
// No optimistic echo: the server relays our own message back to us (it no longer
|
||||
// excludes the sender), so HandleTextMessage renders it through the same path as
|
||||
// every other message. Echoing here too would double it.
|
||||
}
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user