merge main and harden canonical media recovery

This commit is contained in:
Timo
2026-08-25 16:58:15 +02:00
81 changed files with 3422 additions and 1133 deletions
+38 -12
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TIMEOUT, MAX_MEDIA_TIME } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
commitForceSyncMediaState,
@@ -651,10 +651,18 @@ io.on('connection', (socket) => {
// Strip undefined keys for clean wire format
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && !room.forceSyncTarget) {
log('ROOM', `Dropped force_sync_execute without a prepared target from ${mapping.peerId}`);
room.forceSyncInitiator = null;
return;
const mediaStateNow = Date.now();
if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
const forceSyncTarget = room.forceSyncTarget;
const targetExpired = forceSyncTarget
&& (!Number.isFinite(forceSyncTarget.preparedAt)
|| mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TIMEOUT);
if (!forceSyncTarget || targetExpired) {
log('ROOM', `Dropped force_sync_execute ${targetExpired ? 'with an expired target' : 'without a prepared target'} from ${mapping.peerId}`);
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
return;
}
}
if (eventName === EVENTS.PLAY
|| eventName === EVENTS.PAUSE
@@ -671,7 +679,6 @@ io.on('connection', (socket) => {
// Canonical Media State v1: mutate only after rate limiting,
// room mapping, Host Control authorization and sanitization.
// Heartbeats remain observational and never enter this path.
const mediaStateNow = Date.now();
updateMediaStateFromControl(room, eventName, relayPayload, mapping.peerId, {
now: mediaStateNow,
senderPlaybackState: existing.playbackState
@@ -691,7 +698,8 @@ io.on('connection', (socket) => {
room.forceSyncInitiator = mapping.peerId;
room.forceSyncTarget = {
initiatorPeerId: mapping.peerId,
targetTime: relayPayload.targetTime
targetTime: relayPayload.targetTime,
preparedAt: mediaStateNow
};
} else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
const forceSyncTarget = room.forceSyncTarget;
@@ -984,8 +992,7 @@ io.on('connection', (socket) => {
});
// Active Room & Dead Peer Cleanup (Every 2m)
const roomCleanupInterval = setInterval(() => {
const now = Date.now();
export function cleanupInactiveRooms(now = Date.now()) {
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
@@ -1004,7 +1011,13 @@ const roomCleanupInterval = setInterval(() => {
}
for (const sid of staleSids) {
const deadSocket = io.sockets?.sockets?.get(sid);
if (deadSocket) deadSocket.leave(roomId);
if (deadSocket) {
deadSocket.emit(EVENTS.ERROR, {
code: ERROR_CODES.PEER_TIMED_OUT,
message: 'Removed from room after inactivity'
});
deadSocket.leave(roomId);
}
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
try {
removePeerFromRoom(sid, roomId, 'reaper');
@@ -1016,12 +1029,25 @@ const roomCleanupInterval = setInterval(() => {
// 2. Prune empty or inactive rooms
const currentRoom = rooms.get(roomId);
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
io.to(roomId).emit(EVENTS.ERROR, {
code: ERROR_CODES.ROOM_CLOSED,
message: 'Room closed'
});
// A terminal room timeout is a real leave for every member. Clear
// the same socket/peer indexes as an explicit leave so a later join
// cannot be mistaken for the stale membership.
for (const sid of Array.from(currentRoom.peers)) {
const memberSocket = io.sockets?.sockets?.get(sid);
if (memberSocket) memberSocket.leave(roomId);
removePeerFromRoom(sid, roomId, 'room-timeout');
}
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
}
}
}, 2 * 60 * 1000);
}
const roomCleanupInterval = setInterval(cleanupInactiveRooms, 2 * 60 * 1000);
export function startServer(port = PORT, host) {
if (httpServer.listening) return Promise.resolve(httpServer);