feat: add server ping display with peer ping foundation

Adds application-level ping/pong between extension and relay server.
Extension sends PING every 15s, server echoes PONG. Round-trip time
displayed in Status tab (green/yellow/red color-coded).

Server also forwards PING with target peerId and routes PONG back,
enabling future peer-to-peer ping without server restart.
Extension already responds to incoming peer PINGs.

See CHANGELOG.md for details.
This commit is contained in:
KoalaDev
2026-06-09 07:08:14 +02:00
parent 6c6d4dc2a2
commit 3e7afd7592
7 changed files with 151 additions and 5 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.2.0 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.2.1 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
+8
View File
@@ -4,6 +4,14 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.2.1] — 2026-06-09
### Added
- **Server Ping Display**: Measures round-trip latency to the relay server via application-level ping/pong events. The extension sends `PING { t }` every 15 seconds; the server responds with `PONG { t }`. Round-trip time is calculated client-side and displayed in the Status tab, color-coded (<50ms green, 50150ms yellow, >150ms red). No ping value is shown when disconnected or if the server does not respond within 5 seconds.
- **Peer Ping Response (Future-Proof)**: The extension can now respond to incoming `PING { t, sender }` events from other peers by sending back `PONG { t, target: sender }`. The relay server forwards `PING` to the target peer and routes `PONG` back to the original sender. Both client and server validate that peers are in the same room before forwarding/routing. Peer-to-peer ping initiation will be activated in a future extension update without requiring a server restart.
---
## [v2.2.0] — 2026-06-08
### Added
+66 -1
View File
@@ -23,6 +23,12 @@ const lastSeqBySender = {}; // senderId → last received seq (sta
const activePorts = new Set(); // New: track active content ports for keep-alive
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
// --- Ping / Latency ---
let pingInterval = null;
let pingTimeout = null;
let pendingPingT = null;
let currentPingMs = null;
// --- Keep-Alive Port Listener ---
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'keepAlive') {
@@ -299,6 +305,7 @@ function forceDisconnect() {
clearTimeout(forceSyncTimeout);
forceSyncTimeout = null;
}
stopPing();
if (socket) {
socket.onopen = null;
socket.onmessage = null;
@@ -492,6 +499,7 @@ async function connect() {
isConnecting = false;
isNamespaceJoined = true;
broadcastConnectionStatus('connected');
startPing();
addLog('Joined Namespace /', 'success');
const settings = await getSettings();
if (settings.roomId) {
@@ -527,6 +535,7 @@ async function connect() {
socket.onclose = () => {
isConnecting = false;
isNamespaceJoined = false;
stopPing();
isForceSyncInitiator = false;
forceSyncAcks.clear();
@@ -691,6 +700,42 @@ function addToHistory(action, senderId) {
chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {});
}
// --- Ping / Latency ---
function sendPing() {
const t = Date.now();
pendingPingT = t;
emit(EVENTS.PING, { t });
if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
if (pendingPingT === t) {
pendingPingT = null;
}
pingTimeout = null;
}, 5000);
}
function startPing() {
if (pingInterval) clearInterval(pingInterval);
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
currentPingMs = null;
pendingPingT = null;
pingInterval = setInterval(sendPing, 15000);
sendPing();
}
function stopPing() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
currentPingMs = null;
pendingPingT = null;
}
// --- Event Handlers ---
function handleServerEvent(event, data) {
if (!data) {
@@ -875,6 +920,11 @@ function handleServerEvent(event, data) {
routeToContent(event, data);
break;
case EVENTS.PING:
if (data && typeof data.t === 'number' && Number.isFinite(data.t) && data.sender) {
emit(EVENTS.PONG, { t: data.t, target: data.sender });
}
break;
case EVENTS.EVENT_ACK:
if (lastActionState && lastActionState.action && data?.senderId) {
// Correlation Check: Only accept ACK if it matches our current action's timestamp
@@ -1011,6 +1061,20 @@ function handleServerEvent(event, data) {
addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn');
}
break;
case EVENTS.PONG:
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
if (pendingPingT === data.t) {
pendingPingT = null;
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
const rtt = Date.now() - data.t;
currentPingMs = (rtt >= 0 && rtt < 30000) ? rtt : null;
chrome.runtime.sendMessage({ type: 'PING_UPDATE', ping: currentPingMs }).catch(() => {});
}
}
break;
default:
addLog(`Received unknown event from server: ${event}`, 'warn');
break;
@@ -1372,7 +1436,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
serverUrl: currentServerUrl,
version: chrome.runtime.getManifest().version,
protocolVersion: PROTOCOL_VERSION,
roomPassword: currentRoom ? currentRoom.password : null
roomPassword: currentRoom ? currentRoom.password : null,
ping: currentPingMs
});
} else if (message.type === 'LEAVE_ROOM') {
resetAudioProcessingInTab(currentTabId);
+1
View File
@@ -598,6 +598,7 @@
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span>
<span id="connText" style="flex:1;">Disconnected</span>
<span id="connPing" style="font-size:11px; font-family:monospace; font-weight:600; opacity:0.8;"></span>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
</div>
+28 -2
View File
@@ -16,6 +16,7 @@ const elements = {
clearLogs: document.getElementById('clearLogs'),
connDot: document.getElementById('connDot'),
connText: document.getElementById('connText'),
connPing: document.getElementById('connPing'),
serverUrl: document.getElementById('serverUrl'),
serverOfficial: document.getElementById('serverOfficial'),
serverCustom: document.getElementById('serverCustom'),
@@ -248,6 +249,7 @@ async function init() {
localPeerId = res.peerId;
reconnectSlowMode = res.reconnectSlowMode || false;
applyConnectionStatus(res.status);
updatePingDisplay(res.ping);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
@@ -812,6 +814,9 @@ function applyConnectionStatus(status) {
if (elements.connText) {
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : getMessage('STATUS_DISCONNECTED')));
}
if (!connected) {
updatePingDisplay(null);
}
if (elements.retryBtn) {
elements.retryBtn.style.display = reconnecting && reconnectSlowMode ? 'block' : 'none';
}
@@ -831,6 +836,23 @@ function applyConnectionStatus(status) {
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
}
function updatePingDisplay(pingMs) {
if (!elements.connPing) return;
if (pingMs === null || pingMs === undefined || typeof pingMs !== 'number' || !Number.isFinite(pingMs)) {
elements.connPing.textContent = '';
elements.connPing.style.color = '';
return;
}
elements.connPing.textContent = `${Math.round(pingMs)}ms`;
if (pingMs < 50) {
elements.connPing.style.color = '#22c55e';
} else if (pingMs < 150) {
elements.connPing.style.color = '#f59e0b';
} else {
elements.connPing.style.color = '#ef4444';
}
}
function updateHistory(history) {
if (!history || !elements.historyList) return;
elements.historyList.innerHTML = '';
@@ -1559,8 +1581,10 @@ chrome.runtime.onMessage.addListener((msg) => {
}
if (msg.status === 'connected') {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.peers) updatePeerList(res.peers);
if (res && res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
if (!res) return;
if (res.peers) updatePeerList(res.peers);
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
updatePingDisplay(res.ping);
});
}
if (msg.status === 'disconnected') {
@@ -1579,6 +1603,8 @@ chrome.runtime.onMessage.addListener((msg) => {
}
});
}
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {
updateHistory(msg.history);
} else if (msg.type === 'ROOM_LIST') {
+42
View File
@@ -657,6 +657,48 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.PING, (data) => {
if (!checkEventRate(socket.id)) {
socket.disconnect(true);
return;
}
if (!data || typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
if (typeof data.target === 'string' && data.target.length > 0) {
const targetSocketId = peerToSocket.get(data.target);
const senderMapping = socketToRoom.get(socket.id);
if (targetSocketId && senderMapping && data.target !== senderMapping.peerId) {
const targetMapping = socketToRoom.get(targetSocketId);
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.PING, { t: data.t, sender: senderMapping.peerId });
return;
}
}
}
socket.emit(EVENTS.PONG, { t: data.t });
});
socket.on(EVENTS.PONG, (data) => {
if (!checkEventRate(socket.id)) {
socket.disconnect(true);
return;
}
if (!data || typeof data.target !== 'string' || data.target.length === 0) return;
if (typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
const senderMapping = socketToRoom.get(socket.id);
if (!senderMapping || data.target === senderMapping.peerId) return;
const targetSocketId = peerToSocket.get(data.target);
if (!targetSocketId) return;
const targetMapping = socketToRoom.get(targetSocketId);
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.PONG, { t: data.t });
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
+5 -1
View File
@@ -51,7 +51,11 @@ export const EVENTS = {
// Episode Auto-Sync
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
// Ping / Latency
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
PONG: "pong" // server responds with same { t } for client RTT calculation
};
export const HEARTBEAT_INTERVAL = 15000; // 15s