Skip to content

Commit 8108799

Browse files
authored
Merge pull request #42 from reclaimprotocol/feat/viewer-rtt-telemetry
Viewer RTT telemetry with per-region and trend analytics
2 parents 72c1dbf + da9e217 commit 8108799

20 files changed

Lines changed: 1524 additions & 16 deletions

images/minimal-vnc-desktop/host/popcorn-host.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@
700700
case 'POPCORN_KBD_STATE': emit('kbdstate', d); break;
701701
case 'POPCORN_INPUT_DRIFT': emit('inputdrift', d); break;
702702
case 'POPCORN_INTERACTION': emit('interaction', d); break;
703+
case 'POPCORN_RTT': emit('rtt', d); break;
703704
case 'POPCORN_KBD_HEALTH': emit('health', d); break;
704705
// Framebuffer vs CSS vs device-pixel geometry (viewer.js traceScale).
705706
// The one place a "the stream looks blurry" report becomes a number, and
@@ -846,7 +847,7 @@
846847
}
847848

848849
return {
849-
/** Subscribe to viewer events: hello|viewport|connect|frame|disconnect|error|kbdstate|inputdrift|interaction|health|scale|layout */
850+
/** Subscribe to viewer events: hello|viewport|connect|frame|disconnect|error|kbdstate|inputdrift|interaction|health|scale|layout|rtt */
850851
on: function (name, fn) {
851852
(listeners[name] || (listeners[name] = [])).push(fn);
852853
return this;
@@ -862,6 +863,14 @@
862863
toggleMagnify: function () { post('POPCORN_TOGGLE_MAGNIFY', null); },
863864
/** Drive the viewer's keyboard toggle from your own button. */
864865
toggleKeyboard: function () { post('POPCORN_TOGGLE_KBD', null); },
866+
/**
867+
* Ask for the viewer's measured tunnel round trip; the answer arrives as
868+
* .on('rtt', ({rttMs, avgMs, samples}) => ...). rttMs is the latest
869+
* viewer<->pod sample (null before the first pong), avgMs the smoothed
870+
* link latency. A postMessage ping from this page could only time the
871+
* in-device hop, so the viewer's own measurement is the one that matters.
872+
*/
873+
requestRtt: function () { post('POPCORN_RTT_REQUEST', null); },
865874
/**
866875
* Paste text into the focused remote field. Read the clipboard HERE, in the
867876
* gesture handler of your own button: clipboard permission in a nested

images/minimal-vnc-desktop/kbd/host-bridge.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import { dbg } from './diag.js';
3636
import { nowMs } from './env.js';
3737
import { linkLatency } from './latency.js';
38+
import { lastRttMs, rttSampleCount } from './rtt.js';
3839

3940
// Wire-format version. Bump on any BREAKING change to the message shapes; the
4041
// embedder receives it in POPCORN_HELLO and should refuse to drive a viewer it
@@ -259,6 +260,18 @@ function onMessage(e) {
259260
case 'POPCORN_HELLO_REQUEST':
260261
sayHello();
261262
return;
263+
// Link-quality read for the embedder (e.g. a connection badge next to the
264+
// frame). Returns the tunnel round trip the viewer already measures
265+
// (rtt.js) — a host-side postMessage ping could only time the in-device
266+
// hop. rttMs is the latest sample (null before the first pong), avgMs the
267+
// smoothed link latency. Structural integers only; no session state.
268+
case 'POPCORN_RTT_REQUEST':
269+
postToHost('POPCORN_RTT', {
270+
rttMs: lastRttMs(),
271+
avgMs: Math.round(linkLatency()),
272+
samples: rttSampleCount(),
273+
});
274+
return;
262275
case 'POPCORN_HOST_GEOMETRY': {
263276
const vh = Number(d.visibleHeight);
264277
const ob = Number(d.occludedBottom);
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// rtt-report.js — raw RTT sample history + batched shipping to the proxy.
2+
//
3+
// The EMA in ./latency.js keeps the ADAPTIVE state (one live number); this
4+
// module keeps the HISTORY: raw {at, rtt} pairs from the ping probe, batched
5+
// and POSTed to the proxy's /rtstats endpoint so per-session link quality
6+
// reaches analytics instead of dying with the page. Payload is structural
7+
// telemetry only — millisecond integers and elapsed offsets. No field text,
8+
// no page content, no URLs.
9+
//
10+
// Shipping mirrors diag.js's /klog discipline: sendBeacon when available
11+
// (survives unload), fetch as fallback, bounded queue so a black hole endpoint
12+
// cannot grow memory. The session id is parsed from the gateway path
13+
// (/liveview/<session>/<token>/...) when present — the proxy only sees its
14+
// internal path, so this is what lets one pod serve many sessions without
15+
// server-side URL rewriting.
16+
//
17+
// CONSTRAINED-LINK DISCIPLINE (same rule as diag.js): telemetry must never
18+
// compete with the input stream for airtime while a session is actively
19+
// struggling. The periodic timer flush stands down on a constrained link —
20+
// samples stay queued in the ring (which holds ~20min) and ride out with the
21+
// pagehide beacon or a later healthy-window tick. Measuring a bad link must
22+
// not make the bad link worse. The fetch path additionally refuses to stack
23+
// requests behind one that has not settled: on a half-open connection each
24+
// hung POST would otherwise pin one of the browser's few per-host sockets.
25+
26+
import { nowMs, siblingPath } from './env.js';
27+
import { linkLatency } from './latency.js';
28+
29+
const SAMPLE_MAX = 256; // ring bound: ~20min of pings at the 5s mean
30+
const FLUSH_BATCH_MAX = 128;
31+
const FLUSH_MS = 30000;
32+
// One-shot early flush after the first sample: sessions are routinely killed
33+
// within seconds, and at the 30s cadence they'd die with every sample still
34+
// client-side — the teardown readers can only see what reached the proxy.
35+
const EARLY_FLUSH_MS = 3000;
36+
const CONSTRAINED_MS = 700; // matches diag.js's shedding threshold
37+
38+
function rttPath() {
39+
try { return siblingPath('/rtstats'); } catch (_) { return '/rtstats'; }
40+
}
41+
42+
function constrainedLink() {
43+
if (linkLatency() >= CONSTRAINED_MS) return true;
44+
try {
45+
const c = navigator.connection;
46+
if (c && (c.saveData || c.effectiveType === 'slow-2g' || c.effectiveType === '2g')) return true;
47+
} catch (_) {}
48+
return false;
49+
}
50+
51+
// Session correlation. Absent on non-gateway hosts (local harnesses, embeds) —
52+
// callers treat null as "aggregate by connection instead".
53+
export function sessionIdFromPath() {
54+
try {
55+
const m = /\/liveview\/([^/]+)\//.exec(location.pathname);
56+
return m ? m[1].slice(0, 64) : null;
57+
} catch (_) { return null; }
58+
}
59+
60+
let samples = []; // [{at, rtt}] — at is ABSOLUTE (performance.now ms)
61+
let flushTimer = null;
62+
let earlyFlushArmed = false;
63+
let fetchInFlight = false;
64+
65+
function ensureFlushTimer() {
66+
if (flushTimer !== null || typeof setInterval !== 'function') return;
67+
flushTimer = setInterval(function () {
68+
if (constrainedLink()) return; // shed now; pagehide still beacons the ring
69+
flushSamples();
70+
}, FLUSH_MS);
71+
}
72+
73+
// Returns true if the payload was handed to the platform (beacon queued / fetch
74+
// started). A false return means "not shipped" so the caller can keep the data.
75+
function post(payload) {
76+
const body = JSON.stringify(payload);
77+
try {
78+
if (navigator.sendBeacon && body.length < 60000) {
79+
if (navigator.sendBeacon(rttPath(), new Blob([body], { type: 'application/json' }))) return true;
80+
}
81+
} catch (_) {}
82+
if (!window.fetch || fetchInFlight) return false;
83+
fetchInFlight = true;
84+
window.fetch(rttPath(), {
85+
method: 'POST', body, keepalive: true,
86+
headers: { 'Content-Type': 'application/json' },
87+
}).catch(function () {}).then(function () { fetchInFlight = false; });
88+
return true;
89+
}
90+
91+
// Ship the oldest samples, oldest-first, capped per batch. Samples are only
92+
// dropped from the queue once actually handed off; anything not shipped stays
93+
// queued (the ring bound already caps how far behind a dead endpoint can fall).
94+
export function flushSamples() {
95+
if (!samples.length) return;
96+
const batch = samples.slice(0, FLUSH_BATCH_MAX);
97+
const t0 = batch[0].at;
98+
const shipped = post({
99+
sid: sessionIdFromPath(),
100+
t0: Math.round(t0),
101+
// Offsets, not absolute stamps: small payloads, no wall clock leaked.
102+
samples: batch.map((s) => ({ at: Math.round(s.at - t0), rtt: s.rtt })),
103+
});
104+
if (shipped) samples.splice(0, batch.length);
105+
}
106+
107+
export function recordRttSample(rtt) {
108+
if (!(rtt >= 0 && rtt < 20000)) return;
109+
ensureFlushTimer();
110+
if (!earlyFlushArmed && typeof setTimeout === 'function') {
111+
earlyFlushArmed = true;
112+
setTimeout(function () {
113+
if (constrainedLink()) return; // same shedding rule as the periodic timer
114+
flushSamples();
115+
}, EARLY_FLUSH_MS);
116+
}
117+
samples.push({ at: nowMs(), rtt: Math.round(rtt) });
118+
while (samples.length > SAMPLE_MAX) samples.shift();
119+
// Batch-full flush is unconditional (it fires ~once per 10 minutes of pings,
120+
// so it costs nothing even mid-struggle); only the TIMER flush sheds.
121+
if (samples.length >= FLUSH_BATCH_MAX) flushSamples();
122+
}
123+
124+
// Test/observability read of the unsent queue.
125+
export function pendingSampleCount() { return samples.length; }
126+
127+
try {
128+
window.addEventListener('pagehide', flushSamples);
129+
window.addEventListener('beforeunload', flushSamples);
130+
document.addEventListener('visibilitychange', function () { if (document.hidden) flushSamples(); });
131+
} catch (_) {}

images/minimal-vnc-desktop/kbd/rtt.js

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,44 @@
44
// measures live tunnel round-trip time. That seeds the adaptive dismiss/recovery
55
// windows BEFORE the first tap (they'd otherwise be blind at the 1500ms default on
66
// a slow link) and doubles as a NAT keep-alive. Measured round-trips are folded
7-
// into the shared latency EMA (./latency.js).
7+
// into the shared latency EMA (./latency.js) and recorded as raw history for
8+
// analytics shipping (./rtt-report.js).
9+
//
10+
// SAMPLING IS JITTERED PER SESSION. A fixed cadence would synchronize every
11+
// viewer's probes against the same wall clock (page loads cluster, so samples
12+
// correlate and aggregate graphs pulse). Instead each session draws its own
13+
// inter-ping delay uniformly from [PING_MIN_MS, PING_MAX_MS] after every send —
14+
// a renewal process with the same ~5s mean as the old fixed timer, but
15+
// decorrelated across sessions. The scheduling runs on a coarse interval grid
16+
// rather than self-rescheduling timeouts so an idle tab pays one cheap wake
17+
// per second, not a timer churn per ping.
818
//
919
// Owns only its own timer/sequence state; the caller drives it from the /kbd
1020
// socket lifecycle: startPinging(sock) on open, handlePong(msg) on each echo,
1121
// stopPinging() on close.
1222

1323
import { nowMs } from './env.js';
1424
import { noteRtt } from './latency.js';
25+
import { recordRttSample } from './rtt-report.js';
26+
27+
const PING_TICK_MS = 1000;
28+
const PING_MIN_MS = 2000;
29+
const PING_MAX_MS = 8000;
1530

16-
const PING_INTERVAL_MS = 5000;
1731
let pingSeq = 0;
1832
let pingTimer = null;
33+
let lastRtt = null; // latest measured round trip (ms); null until the first pong
34+
let rttCount = 0; // pongs measured this page
35+
let lastSentAt = 0;
36+
let nextDelayMs = 0;
1937
const pendingPings = new Map(); // id -> sent time
2038

39+
// Per-send renewal draw. Uniform integers; mean (MIN+MAX)/2 = 5000ms matches the
40+
// historic fixed cadence, so downstream expectations about sample density hold.
41+
function drawInterval() {
42+
return PING_MIN_MS + Math.floor(Math.random() * (PING_MAX_MS - PING_MIN_MS + 1));
43+
}
44+
2145
export function stopPinging() {
2246
if (pingTimer !== null) { clearInterval(pingTimer); pingTimer = null; }
2347
pendingPings.clear();
@@ -32,14 +56,33 @@ export function startPinging(s) {
3256
if (pendingPings.size > 8) pendingPings.delete(pendingPings.keys().next().value);
3357
try { s.send(JSON.stringify({ t: 'ping', id })); } catch (_) {}
3458
};
35-
send();
36-
pingTimer = setInterval(send, PING_INTERVAL_MS);
59+
send(); // first sample immediately, like always — it seeds the EMA earliest
60+
lastSentAt = nowMs();
61+
nextDelayMs = drawInterval();
62+
const tick = () => {
63+
const t = nowMs();
64+
if (t - lastSentAt < nextDelayMs) return;
65+
if (s.readyState !== WebSocket.OPEN) { lastSentAt = t; return; }
66+
send();
67+
lastSentAt = t;
68+
nextDelayMs = drawInterval();
69+
};
70+
pingTimer = setInterval(tick, PING_TICK_MS);
3771
}
3872

3973
export function handlePong(msg) {
4074
const sentAt = pendingPings.get(msg.id);
4175
if (sentAt == null) return;
4276
pendingPings.delete(msg.id);
4377
const rtt = nowMs() - sentAt;
44-
if (rtt >= 0 && rtt < 20000) noteRtt(rtt);
78+
if (rtt >= 0 && rtt < 20000) {
79+
lastRtt = Math.round(rtt);
80+
rttCount += 1;
81+
noteRtt(rtt);
82+
recordRttSample(lastRtt);
83+
}
4584
}
85+
86+
// Read by the host bridge to answer POPCORN_RTT_REQUEST.
87+
export function lastRttMs() { return lastRtt; }
88+
export function rttSampleCount() { return rttCount; }

images/minimal-vnc-desktop/kbd/signal.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ export function createSignal({ applySignal, applyDialog, applyPopup, kickInput,
9898
}
9999

100100
// Client-side liveness watchdog for the /kbd viewer socket. Our RTT ping echoes
101-
// back every 5s and the server pings every 30s, so a healthy pipe stamps
101+
// back every 2–8s (jittered per session, see rtt.js) and the server pings every
102+
// 30s, so a healthy pipe stamps
102103
// lastKbdMsgAt continuously. On a lossy mobile link a half-open socket (wifi<->
103104
// cell handoff, NAT rebind) can sit readyState=OPEN with NO data for minutes —
104105
// every focus signal lost, taps hitting stale rects. If nothing arrives for 45s
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// host-rtt.test.mjs — characterization for the host bridge's RTT read.
2+
//
3+
// An embedder cannot measure the tunnel round trip itself: a postMessage ping
4+
// only times the in-device hop. POPCORN_RTT_REQUEST must therefore return the
5+
// viewer's OWN measurement (rtt.js), through the same fail-closed inbound gate
6+
// as every other host command.
7+
import { test } from 'node:test';
8+
import assert from 'node:assert/strict';
9+
import {
10+
installGlobals, freshViewer, fireHostMessage, parentMessages, advanceClock,
11+
} from './stub-dom.mjs';
12+
import { createMockRfb } from './mock-rfb.mjs';
13+
import { makeHostWindow } from './host-stub.mjs';
14+
15+
installGlobals('ios', { embedded: true, search: '?parentOrigin=https://portal.test' });
16+
17+
const { startPinging, handlePong } = await import('../rtt.js');
18+
19+
const lastRttMsg = () => parentMessages.filter((m) => m.type === 'POPCORN_RTT').at(-1) ?? null;
20+
21+
function freshSocket() {
22+
return { readyState: 1 /* OPEN */, sent: [], send(data) { this.sent.push(data); } };
23+
}
24+
25+
test('POPCORN_RTT_REQUEST answers with null before any pong was measured', async () => {
26+
await freshViewer(createMockRfb);
27+
parentMessages.length = 0;
28+
fireHostMessage({ type: 'POPCORN_RTT_REQUEST' });
29+
const msg = lastRttMsg();
30+
assert.ok(msg, 'POPCORN_RTT posted');
31+
assert.equal(msg.rttMs, null, 'no measurement yet');
32+
assert.equal(msg.samples, 0);
33+
});
34+
35+
test('POPCORN_RTT_REQUEST returns the measured tunnel round trip', async () => {
36+
const s = freshSocket();
37+
startPinging(s);
38+
const pingId = JSON.parse(s.sent.at(-1)).id;
39+
advanceClock(120);
40+
handlePong({ id: pingId });
41+
42+
parentMessages.length = 0;
43+
fireHostMessage({ type: 'POPCORN_RTT_REQUEST' });
44+
const msg = lastRttMsg();
45+
assert.ok(msg, 'POPCORN_RTT posted');
46+
assert.equal(msg.rttMs, 120, 'latest measured round trip');
47+
assert.ok(msg.samples >= 1, 'sample count carried');
48+
assert.ok(Number.isFinite(msg.avgMs), 'smoothed latency carried');
49+
});
50+
51+
test('the RTT read obeys the fail-closed inbound gate (wrong origin ignored)', async () => {
52+
parentMessages.length = 0;
53+
fireHostMessage({ type: 'POPCORN_RTT_REQUEST' }, { origin: 'https://evil.example' });
54+
assert.equal(lastRttMsg(), null, 'request from a non-configured origin is dropped');
55+
});
56+
57+
// ---- host side (PopcornHost SDK) ----------------------------------------
58+
59+
test('PopcornHost.requestRtt posts the request and surfaces the reply as .on(\'rtt\')', async () => {
60+
const h = makeHostWindow({ top: true, iframeStyle: { position: 'fixed' },
61+
iframeRect: { left: 0, top: 0, width: 411, height: 732 } });
62+
const host = h.PopcornHost.attach(h.iframe, { childOrigin: 'https://pod.test' });
63+
64+
const got = [];
65+
host.on('rtt', (d) => got.push(d));
66+
host.requestRtt();
67+
assert.ok(h.posted.some((m) => m.type === 'POPCORN_RTT_REQUEST'), 'request posted to the frame');
68+
69+
h.fromChild({ type: 'POPCORN_RTT', rttMs: 86, avgMs: 92, samples: 41 });
70+
assert.equal(got.length, 1, 'rtt event emitted');
71+
assert.equal(got[0].rttMs, 86);
72+
assert.equal(got[0].samples, 41);
73+
});

0 commit comments

Comments
 (0)