mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-17 14:57:49 +00:00
fix(host-control-mode): adversarial audit — force-sync stall, desync/lobby, gate parity, BC tests
Audit fixes (each verified against the actual code path): H-1 (server): track force-sync initiator on PREPARE; let the demoted initiator's EXECUTE through the host-only gate so mid-sync demotion no longer strands the whole room paused. Clear on EXECUTE/peer-leave. M-1 (background): episode-lobby gate now uses !amController() for parity with CONTENT_EVENT and server gates — co-hosts can drive the room and initiate lobbies, not just the owner. M-2/M-3 (popup/content/background): forceSyncReset respects hcmGuestLocked; desynced guest skips EPISODE_LOBBY so they don't get frozen in pause after lobby completion, and checkEpisodeLobbyCompletion excludes desynced peers from the required count so they don't block the lobby. M-4 (background): getHostSyncTarget clamps extrapolation to 2x heartbeat interval so a stale host heartbeat can't snap the guest tens of seconds past the host's real position. L-1..L-4 (server/content/background/popup): clarify dedup comment re: network-blip window, enforce desync invariant on SW-restore, add forceSyncBtn guest-locked backstop, refresh badge text in place. Backward compatibility (verified by BC-1..BC-4 regression tests): - Old client ↔ new server: server adds fields only, never requires; old heartbeats stripped of desynced; host-only enforced server-side even when the client has no awareness. - New client ↔ old server: empty capabilities → host-control UI hidden, all gates default to everyone, behavior byte-identical to pre-HCM. - Mixed rooms: every pre-HCM event type relays cleanly in both directions.
This commit is contained in:
+34
-6
@@ -247,15 +247,21 @@ function removePeerFromRoom(socketId, roomId, reason) {
|
||||
// reassign host to the earliest remaining peer so the feature stays usable.
|
||||
// (v1: immediate fallback, no grace period — see host-control-mode docs.)
|
||||
// Skip while a join for this peerId is in flight (peerJoinLocks holds it):
|
||||
// that's a reconnect / second tab where the same peerId is being re-added
|
||||
// right after — covers both the explicit 'dedupe' removal AND the
|
||||
// 'disconnect' the kicked old socket fires. Demoting there would silently
|
||||
// unlock the room on every host network blip.
|
||||
// that's the reconnect / second-tab case where the same peerId is being
|
||||
// re-added right after the dedupe kicked the old socket. Demoting there
|
||||
// would silently unlock the room on every second-tab open.
|
||||
// NOTE: this does *not* cover a true network blip where the old socket's
|
||||
// 'disconnect' fires before the new socket acquires its join lock — in that
|
||||
// window peerJoinLocks is empty, so the fallback fires. Documented v1
|
||||
// limitation (no host grace period); see KNOWN_LIMITATIONS.md.
|
||||
const peerRejoining = peerJoinLocks.has(peerId);
|
||||
const peerGone = !isPeerStillConnected && !peerRejoining;
|
||||
if (peerGone && room.controllers && room.peers.size > 0) {
|
||||
const wasController = room.controllers.has(peerId);
|
||||
room.controllers.delete(peerId);
|
||||
// H-1: a leaving initiator strands the room's force-sync — release the
|
||||
// slot so a future controller's PREPARE can take over cleanly.
|
||||
if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null;
|
||||
if (room.hostPeerId === peerId) {
|
||||
// Owner left → reassign owner + fall back to 'everyone' so the room is
|
||||
// never stuck locked, and reset the controller set to just the new owner.
|
||||
@@ -410,7 +416,12 @@ io.on('connection', (socket) => {
|
||||
controlMode: CONTROL_MODES.EVERYONE,
|
||||
lastControlModeChangeAt: 0, // M-4: per-room debounce for control-mode toggles
|
||||
// Co-Host: peers allowed to drive in 'host-only'. Always includes the owner.
|
||||
controllers: new Set([peerId])
|
||||
controllers: new Set([peerId]),
|
||||
// H-1: peerId of the in-flight force-sync initiator. Lets a demoted
|
||||
// controller's FORCE_SYNC_EXECUTE through the host-only gate — without
|
||||
// it, demoting a co-host mid-force-sync would drop their EXECUTE and
|
||||
// leave every peer stuck paused.
|
||||
forceSyncInitiator: null
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
createdByMe = true;
|
||||
@@ -544,12 +555,29 @@ io.on('connection', (socket) => {
|
||||
// In 'host-only' mode, drop room-moving events from anyone who is not
|
||||
// a controller (the owner + any promoted co-hosts). Robust chokepoint:
|
||||
// independent of client behavior, kills spam. Heartbeats/ACKs pass.
|
||||
if (room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
//
|
||||
// H-1 exception: a demoted co-host's FORCE_SYNC_EXECUTE still has to
|
||||
// land — otherwise their already-relayed PREPARE would leave the whole
|
||||
// room stuck paused. Track the in-flight initiator on PREPARE and let
|
||||
// their matching EXECUTE through regardless of current controllers set.
|
||||
if (eventName === EVENTS.FORCE_SYNC_PREPARE &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
room.controllers && room.controllers.has(mapping.peerId)) {
|
||||
room.forceSyncInitiator = mapping.peerId;
|
||||
}
|
||||
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE &&
|
||||
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator;
|
||||
if (!isOwnForceSyncExecute &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
!(room.controllers && room.controllers.has(mapping.peerId)) &&
|
||||
HOST_ONLY_GATED_EVENTS.has(eventName)) {
|
||||
log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`);
|
||||
return;
|
||||
}
|
||||
// Clear initiator tracking once the EXECUTE has been relayed.
|
||||
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator) {
|
||||
room.forceSyncInitiator = null;
|
||||
}
|
||||
|
||||
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
|
||||
|
||||
Reference in New Issue
Block a user