Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/server/__tests__/hub.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
fanout,
fanoutLossy,
FEED_BACKLOG_LIMIT_BYTES,
OPEN_READY_STATE,
selectRevokedSockets,
sweepLiveness,
Expand Down Expand Up @@ -42,6 +44,39 @@ describe('WebSocket hub helpers', () => {
expect(healthy.sent).toEqual(['payload']);
});

it('skips a backed-up client on the state feed instead of queueing frames', () => {
// A client that cannot keep up used to accumulate a queue it had to drain
// before it could show the present, on a socket that never looked broken.
const behind = fakeSocket({ bufferedAmount: FEED_BACKLOG_LIMIT_BYTES + 1 });
const keepingUp = fakeSocket({ bufferedAmount: 1_024 });
const unknownBuffer = fakeSocket();
const failed: HubSocket[] = [];

const delivered = fanoutLossy([behind, keepingUp, unknownBuffer], 'frame', (s) =>
failed.push(s)
);

expect(delivered).toBe(2);
expect(behind.sent).toEqual([]);
expect(keepingUp.sent).toEqual(['frame']);
expect(unknownBuffer.sent).toEqual(['frame']);
expect(failed).toEqual([]);
});

it('still isolates a throwing client on the state feed', () => {
const throwing = fakeSocket({
bufferedAmount: 0,
send() {
throw new Error('gone');
}
});
const healthy = fakeSocket();
const failed: HubSocket[] = [];

expect(fanoutLossy([throwing, healthy], 'frame', (s) => failed.push(s))).toBe(1);
expect(failed).toEqual([throwing]);
});

it('terminates missed peers and pings peers that answered', () => {
const missed = fakeSocket();
const responsive = fakeSocket();
Expand Down
39 changes: 39 additions & 0 deletions packages/server/src/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const WS_REASON_SESSION_REVOKED = 'session revoked';

export interface HubSocket {
readyState: number;
/** Bytes the socket has accepted but not yet written to the network. */
bufferedAmount?: number;
send(payload: string): void;
close(code?: number, reason?: string): void;
terminate(): void;
Expand All @@ -18,6 +20,14 @@ export interface LivenessState {
alive: boolean;
}

/**
* How much unwritten payload makes a socket "behind" for a superseded feed.
* Roughly a second of state frames for a large grid: enough that an ordinary
* hiccup rides through, small enough that a client can never accumulate a
* queue of stale frames it has to drain before showing the present.
*/
export const FEED_BACKLOG_LIMIT_BYTES = 512 * 1024;

/** Send to every open socket without letting one broken peer stop the fanout. */
export function fanout<T extends HubSocket>(
sockets: Iterable<T>,
Expand All @@ -37,6 +47,35 @@ export function fanout<T extends HubSocket>(
return delivered;
}

/**
* Fanout for a feed where every message supersedes the last (grid state at
* 60fps): a client that cannot keep up skips frames instead of queueing them.
*
* Queueing is what freezes a UI while the rig keeps running — the socket stays
* healthy, so nothing reconnects, and the client renders a backlog that is
* already seconds old. Dropping frames means the next one it does get is the
* present.
*/
export function fanoutLossy<T extends HubSocket>(
sockets: Iterable<T>,
payload: string,
onFailure: (socket: T, error: unknown) => void,
limitBytes = FEED_BACKLOG_LIMIT_BYTES
): number {
let delivered = 0;
for (const socket of sockets) {
if (socket.readyState !== OPEN_READY_STATE) continue;
if ((socket.bufferedAmount ?? 0) > limitBytes) continue;
try {
socket.send(payload);
delivered++;
} catch (error) {
onFailure(socket, error);
}
}
return delivered;
}

/**
* Mark responsive sockets for the next sweep and terminate peers that missed
* the previous ping.
Expand Down
7 changes: 5 additions & 2 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { computeCoverage } from './coverage';
import type { BlendMode, CannonState, Orientation, Rotation } from './grid';
import {compositeLayer, createGrid, DEFAULT_ALPHA, defaultOrientation, mapUiToGrid, remapGridForUi, resetGrid, setAllTargets, setCannonTarget, shiftGrid, tickGrid } from './grid';
import { createHttpApp, lanVisitors, resolveUiDir } from './http-app';
import { fanout, type LivenessState,selectRevokedSockets, sweepLiveness, WS_CLOSE_SESSION_REVOKED, WS_REASON_SESSION_REVOKED } from './hub';
import { fanout, fanoutLossy, type LivenessState,selectRevokedSockets, sweepLiveness, WS_CLOSE_SESSION_REVOKED, WS_REASON_SESSION_REVOKED } from './hub';
import type { JwtPayload } from './jwt';
import { verifyJwt } from './jwt';
import { ServerPatternEngine } from './pattern-engine';
Expand Down Expand Up @@ -305,7 +305,10 @@ function broadcastState() {
GRID_COLUMNS, GRID_ROWS, orientation
);
const payload = JSON.stringify({ type: 'state', grid: output });
fanout(wss.clients, payload, dropClient);
// Lossy: this goes out 60 times a second and each frame replaces the last, so
// a client that cannot keep up must skip frames rather than build a backlog
// that leaves it rendering the past while the rig runs on the present.
fanoutLossy(wss.clients, payload, dropClient);
}

function getCalibrationOutput(): CannonState[] {
Expand Down
26 changes: 26 additions & 0 deletions packages/ui/src/lib/use-socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export function useSocket(
let attempts = 0;
let retry: ReturnType<typeof setTimeout> | null = null;
let watchdog: ReturnType<typeof setInterval> | null = null;
/** Closed on purpose because the page is hidden, not because it broke. */
let suspended = false;

const probe = async (path: string) => {
const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } });
Expand Down Expand Up @@ -76,6 +78,7 @@ export function useSocket(
});
// A revoked session can only reconnect into the same rejection, so stop
// hammering the brain and let the app hand back the login screen.
if (suspended) return;
if (!isSessionEndedCode(e.code)) retry = setTimeout(connect, retryDelay(attempts));
};

Expand All @@ -94,6 +97,28 @@ export function useSocket(
};

connect();

// A hidden page cannot paint the 60fps state feed and its timers are
// throttled, so the frames pile up in the socket instead: it comes back to
// a backlog of stale state on a socket that never looked broken. Dropping
// the connection while hidden and taking a fresh one on return means what
// the operator sees is always the present.
const onVisibility = () => {
if (document.visibilityState === 'hidden') {
suspended = true;
if (retry) clearTimeout(retry);
retry = null;
wsRef.current?.close();
wsRef.current = null;
return;
}
if (!suspended) return;
suspended = false;
attempts = 0;
if (!wsRef.current) connect();
};
document.addEventListener('visibilitychange', onVisibility);

watchdog = setInterval(() => {
const ws = wsRef.current;
if (
Expand All @@ -104,6 +129,7 @@ export function useSocket(

return () => {
disposed = true;
document.removeEventListener('visibilitychange', onVisibility);
if (retry) clearTimeout(retry);
if (watchdog) clearInterval(watchdog);
wsRef.current?.close();
Expand Down
Loading