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
19 changes: 19 additions & 0 deletions proxy/gameSocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as net from 'net';

/** How long the game socket may sit idle before the kernel starts probing it.
* Node arms the probes themselves at 1 s apart, 10 of them (libuv's
* `uv_tcp_keepalive`), so a game host that vanished without closing — a
* crash, a power loss, a partition, a NAT that forgot the flow — is found
* about 70 s after the last traffic. Desktop Mudlet keeps the same watch on
* its socket (issue #454). */
export const GAME_KEEPALIVE_DELAY_MS = 60_000;

/** Turn on TCP keepalive for a socket to the game. Without it a peer that
* disappeared silently leaves the socket half-open until the proxy next
* writes to it: no error, no close, and so nothing for the browser to react
* to. With it, failed probes raise ETIMEDOUT, the socket closes, and the
* proxy reports that as a WebSocket close the client reconnects from. */
export function armGameKeepAlive<T extends net.Socket>(socket: T): T {
socket.setKeepAlive(true, GAME_KEEPALIVE_DELAY_MS);
return socket;
}
5 changes: 4 additions & 1 deletion proxy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as http from 'http';
import * as net from 'net';
import * as tls from 'tls';
import { WebSocketServer, WebSocket } from 'ws';
import { armGameKeepAlive } from './gameSocket';

const PORT = parseInt(process.env.PORT ?? '3001', 10);

Expand Down Expand Up @@ -293,6 +294,8 @@ wss.on('connection', (ws, req) => {

if (tlsPrefs.enabled) {
const secure = tls.connect({ host, port, servername: host, rejectUnauthorized: false });
// Keepalive on, as on the plain path below: see gameSocket.ts (issue #454).
armGameKeepAlive(secure);
secure.on('secureConnect', () => {
tcpConnected = true;
const cert = secure.getPeerCertificate();
Expand Down Expand Up @@ -326,7 +329,7 @@ wss.on('connection', (ws, req) => {
});
tcp = secure;
} else {
tcp = net.connect(port, host);
tcp = armGameKeepAlive(net.connect(port, host));
tcp.on('connect', () => {
tcpConnected = true;
console.log(`[proxy] Connected to ${host}:${port}`);
Expand Down
72 changes: 72 additions & 0 deletions src/mud/connection/MudClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,14 @@ export class MudClient {
* the several signals that can prove the game leg is up all funnel through
* {@link markEstablished}, and only the first of them counts. */
private gameEstablished = false;
/** Fires while the game has been silent for {@link IDLE_PROBE_MS}; see
* {@link probeIdleConnection}. */
private idleProbe: ReturnType<typeof setTimeout> | null = null;
/** `performance.now()` of the last game frame on the current socket. */
private lastInboundAt = 0;
/** True once the server has sent a telnet command (any IAC) on the current
* socket — only then is an `IAC NOP` probe something it knows to ignore. */
private telnetSeen = false;

commandEcho: boolean;
private readonly mspParser = new MspParser();
Expand Down Expand Up @@ -684,6 +692,8 @@ export class MudClient {
// Redialling re-runs the handshake, so the previous verdict is stale.
this.tlsResolved = false;
this.gameEstablished = false;
this.telnetSeen = false;
this.stopIdleProbe();
if (this.tlsDeadline !== null) {
clearTimeout(this.tlsDeadline);
this.tlsDeadline = null;
Expand Down Expand Up @@ -720,7 +730,9 @@ export class MudClient {
// …and for the same reason it proves the game leg is up,
// which is the fallback for a proxy too old to say so.
this.markEstablished();
this.lastInboundAt = receivedAt;
const decodedData = bytesToLatin1(new Uint8Array(event.data));
if (!this.telnetSeen && decodedData.includes('\xff')) this.telnetSeen = true;
if (debugTelnetEnabled()) logTelnetNegotiation('raw', decodedData);
this.inbound.push({ kind: 'read', data: decodedData, receivedAt });
this.pumpInbound();
Expand Down Expand Up @@ -759,6 +771,7 @@ export class MudClient {
this.pendingTelnet = "";
this.negotiator.clearMspNegotiated();
this.cancelCharacterModeDetection();
this.stopIdleProbe();
};

this.socket.onopen = (event: Event) => {
Expand Down Expand Up @@ -821,6 +834,8 @@ export class MudClient {
private markEstablished(): void {
if (this.gameEstablished) return;
this.gameEstablished = true;
this.lastInboundAt = performance.now();
this.armIdleProbe(IDLE_PROBE_MS);
this.eventBus.emit('client.connect');
this.eventBus.emit('client.established');
}
Expand Down Expand Up @@ -971,9 +986,58 @@ export class MudClient {
this.pendingTelnet = '';
this.negotiator.clearMspNegotiated();
this.cancelCharacterModeDetection();
this.stopIdleProbe();
this.latencyStartedAt = null;
}

private armIdleProbe(delay: number): void {
this.stopIdleProbe();
this.idleProbe = setTimeout(() => {
this.idleProbe = null;
this.probeIdleConnection();
}, delay);
}

private stopIdleProbe(): void {
if (this.idleProbe !== null) {
clearTimeout(this.idleProbe);
this.idleProbe = null;
}
}

/** Make a long-silent connection prove it is still there (issue #454). A
* game host that dies without closing leaves the WebSocket looking healthy
* — through a proxy the browser's leg really is — so nothing ever closes,
* nothing reconnects, and the player's next command goes into the dead
* socket. An `IAC NOP` costs the game nothing (telnet servers discard it),
* but it puts bytes on every leg: a peer that has come back answers them
* with a reset, one that is gone fails their retransmission, and either
* way the socket errors and closes, which is the ordinary disconnect and
* auto-reconnect path. It also keeps a NAT from forgetting a quiet flow.
*
* This is the client's own check, for a direct `ws(s)://` endpoint or a
* proxy without TCP keepalive. The bundled proxy keeps that watch on the
* game socket itself (`proxy/gameSocket.ts`) and finds a dead peer in about
* 70 s — before this probe's first NOP, which would otherwise hold off its
* keepalive by leaving data unacknowledged. */
private probeIdleConnection(): void {
const socket = this.socket;
if (!socket || socket.readyState !== WebSocket.OPEN || !this.gameEstablished) return;
const idle = performance.now() - this.lastInboundAt;
if (idle < IDLE_PROBE_MS) {
this.armIdleProbe(IDLE_PROBE_MS - idle);
return;
}
if (this.telnetSeen) {
try {
this.sendBytes(GMCP_IAC + TELNET_NOP);
} catch (error) {
console.error('Error sending idle probe:', error);
}
}
this.armIdleProbe(IDLE_PROBE_MS);
}

/** Raise the disconnect, then hand over the text the final flush left in
* the message buffer. cTelnet raises sysDisconnectionEvent first and only
* then is the unterminated last line printed and its triggers run — a
Expand Down Expand Up @@ -1673,6 +1737,14 @@ export class MudClient {
* no error and no close at all, so silence is the only symptom there is. */
const TLS_HANDSHAKE_TIMEOUT_MS = 12_000;

/** How long the game may stay silent before the client probes the connection
* with an `IAC NOP` (and again each time as long again passes). Past the
* ~70 s the bundled proxy's keepalive takes, so the two don't overlap. */
export const IDLE_PROBE_MS = 120_000;

/** Telnet NOP (RFC 854). */
const TELNET_NOP = '\xf1';

/** How long ECHO+SGA must survive a submitted input line before it counts as
* character-at-a-time rather than a password mask — `cTelnet`'s
* CHARACTER_MODE_DETECT. See {@link MudClient.checkCharacterModePattern}. */
Expand Down
177 changes: 177 additions & 0 deletions tests/mud/connection/idleProbe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MudClient, IDLE_PROBE_MS } from '../../../src/mud/connection/MudClient';
import { EventBus } from '../../../src/core/EventBus';
import type { MudClientEvents } from '../../../src/mud/events';

class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
static instances: MockWebSocket[] = [];

readyState = MockWebSocket.OPEN;
binaryType = '';
bufferedAmount = 0;
sent: Uint8Array[] = [];
onopen: ((ev: unknown) => void) | null = null;
onmessage: ((ev: { data: ArrayBuffer | string }) => void) | null = null;
onclose: ((ev: unknown) => void) | null = null;
onerror: ((ev: unknown) => void) | null = null;

constructor(public url: string) { MockWebSocket.instances.push(this); }
send(bytes: Uint8Array) { this.sent.push(bytes); }
close() { this.readyState = MockWebSocket.CLOSED; }

deliverBinary(byteString: string) {
const buf = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) buf[i] = byteString.charCodeAt(i) & 0xff;
this.onmessage?.({ data: buf.buffer });
}
deliverControl(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) });
}
closeWith(ev: { code?: number; reason?: string; wasClean?: boolean } = {}) {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: ev.code ?? 1006, reason: ev.reason ?? '', wasClean: ev.wasClean ?? false });
}
/** Every outbound IAC NOP. */
nops(): number {
return this.sent.filter(b => b.length === 2 && b[0] === 0xff && b[1] === 0xf1).length;
}
}

const DIRECT_URL = 'wss://mud.example.org/ws';
const PROXY_URL = 'ws://proxy.invalid/?host=mud.example.org&port=23';
// IAC WILL ECHO — any telnet command shows the server speaks telnet.
const TELNET_HELLO = '\xff\xfb\x01Welcome\r\n';

/** Issue #454: a game host that dies without closing must not leave the
* client sitting on a connection it believes is live. */
describe('idle connection probe (issue #454)', () => {
let realWebSocket: unknown;
let realAddEventListener: unknown;

beforeEach(() => {
realWebSocket = (globalThis as Record<string, unknown>).WebSocket;
realAddEventListener = (globalThis as Record<string, unknown>).addEventListener;
(globalThis as Record<string, unknown>).WebSocket = MockWebSocket as unknown;
(globalThis as Record<string, unknown>).addEventListener = () => {};
MockWebSocket.instances = [];
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date', 'performance'] });
});
afterEach(() => {
vi.useRealTimers();
(globalThis as Record<string, unknown>).WebSocket = realWebSocket;
(globalThis as Record<string, unknown>).addEventListener = realAddEventListener;
});

function open(url = DIRECT_URL) {
const bus = new EventBus<MudClientEvents>();
const client = new MudClient({ url }, bus);
client.connect();
const sock = MockWebSocket.instances[0];
sock.onopen?.({});
return { client, sock, bus };
}

function settle() {
// Let the inbound pump (MessageChannel/timeouts) handle what arrived.
vi.advanceTimersByTime(0);
}

it('sends IAC NOP once the game has been silent for the probe interval', () => {
const { sock } = open();
sock.deliverBinary(TELNET_HELLO);
settle();

vi.advanceTimersByTime(IDLE_PROBE_MS - 1000);
expect(sock.nops()).toBe(0);
const before = sock.sent.length;
vi.advanceTimersByTime(1000);
expect(sock.nops()).toBe(1);
// A NOP and nothing else: no command reaches the game.
expect(sock.sent.length).toBe(before + 1);
});

it('keeps probing while the silence lasts', () => {
const { sock } = open();
sock.deliverBinary(TELNET_HELLO);
settle();
vi.advanceTimersByTime(IDLE_PROBE_MS * 3);
expect(sock.nops()).toBe(3);
});

it('game output pushes the next probe back', () => {
const { sock } = open();
sock.deliverBinary(TELNET_HELLO);
settle();
vi.advanceTimersByTime(IDLE_PROBE_MS - 10_000);
sock.deliverBinary('A rat scurries past.\r\n');
settle();
vi.advanceTimersByTime(IDLE_PROBE_MS - 10_000);
expect(sock.nops()).toBe(0);
vi.advanceTimersByTime(10_000);
expect(sock.nops()).toBe(1);
});

it('never probes a server that has not spoken telnet', () => {
const { sock } = open();
sock.deliverBinary('plain text only\r\n');
settle();
vi.advanceTimersByTime(IDLE_PROBE_MS * 3);
expect(sock.nops()).toBe(0);
});

it('through a proxy, waits for the game leg and starts after the proxy keepalive would have', () => {
const { sock } = open(PROXY_URL);
// Proxy accepted us but has not reached the game: nothing to probe.
vi.advanceTimersByTime(IDLE_PROBE_MS * 2);
expect(sock.nops()).toBe(0);

sock.deliverControl({ type: 'game.connected' });
sock.deliverBinary(TELNET_HELLO);
settle();
// The proxy's keepalive finds a dead game in ~70 s; the probe is later.
expect(IDLE_PROBE_MS).toBeGreaterThan(70_000);
vi.advanceTimersByTime(IDLE_PROBE_MS);
expect(sock.nops()).toBe(1);
});

it('stops probing once the connection closes, and a dead socket ends in a disconnect', () => {
const { sock, bus } = open();
let disconnects = 0;
bus.on('client.disconnect', () => { disconnects++; });
sock.deliverBinary(TELNET_HELLO);
settle();
vi.advanceTimersByTime(IDLE_PROBE_MS);
expect(sock.nops()).toBe(1);

// The probe's bytes hit the dead peer; the browser reports the failure.
sock.closeWith({ code: 1006 });
expect(disconnects).toBe(1);
vi.advanceTimersByTime(IDLE_PROBE_MS * 3);
expect(sock.nops()).toBe(1);
});

it('stops probing after disconnect()', () => {
const { client, sock } = open();
sock.deliverBinary(TELNET_HELLO);
settle();
client.disconnect();
vi.advanceTimersByTime(IDLE_PROBE_MS * 3);
expect(sock.nops()).toBe(0);
});

it('a redial starts the count afresh and forgets the old server spoke telnet', () => {
const { client, sock } = open();
sock.deliverBinary(TELNET_HELLO);
settle();
client.connect();
const next = MockWebSocket.instances[1];
next.onopen?.({});
next.deliverBinary('no telnet here\r\n');
settle();
vi.advanceTimersByTime(IDLE_PROBE_MS * 2);
expect(sock.nops() + next.nops()).toBe(0);
});
});
Loading
Loading