From e5544fa8e062463212a88cd04b772e4cd64a4d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:07:27 +0000 Subject: [PATCH 1/2] Detect a game host that dies silently (#454) The proxy dialled the game with net.connect/tls.connect and never turned on TCP keepalive, so a peer that vanished without closing (crash, power loss, partition, NAT drop) left the socket half-open until the next write. The browser's WebSocket stayed healthy, nothing disconnected or reconnected, and the player's next command was written into the dead socket and lost. - proxy: dial the game through connectGame() (proxy/gameSocket.ts), which arms keepalive with a 60 s idle delay on both the plain and TLS paths. Node probes 10 times at 1 s, so a dead peer raises ETIMEDOUT about 70 s after the last traffic; the proxy already turns that into a WebSocket close, which the client reports and auto-reconnects from. - client: after 120 s of game silence, MudClient sends IAC NOP (repeating while the silence lasts), only to servers that have spoken telnet. That covers direct ws(s):// endpoints and proxies without keepalive: a peer that came back answers with a reset, one that is gone fails retransmission, and either way the socket closes through the normal disconnect path. The delay is past the bundled proxy's keepalive window so a probe never holds that off by leaving data unacknowledged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dkC1X3wo4Y9wi9RoCrQpU --- proxy/gameSocket.ts | 29 ++++ proxy/server.ts | 6 +- src/mud/connection/MudClient.ts | 72 ++++++++++ tests/mud/connection/idleProbe.test.ts | 177 +++++++++++++++++++++++++ tests/proxy/gameKeepAlive.test.ts | 52 ++++++++ 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 proxy/gameSocket.ts create mode 100644 tests/mud/connection/idleProbe.test.ts create mode 100644 tests/proxy/gameKeepAlive.test.ts diff --git a/proxy/gameSocket.ts b/proxy/gameSocket.ts new file mode 100644 index 00000000..90b8fd4d --- /dev/null +++ b/proxy/gameSocket.ts @@ -0,0 +1,29 @@ +import * as net from 'net'; +import * as tls from 'tls'; + +/** 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(socket: T): T { + socket.setKeepAlive(true, GAME_KEEPALIVE_DELAY_MS); + return socket; +} + +/** Dial the game, plain or over TLS, with keepalive on either way. TLS + * verification is left to the caller (it inspects the cert itself), hence + * `rejectUnauthorized: false`. */ +export function connectGame(host: string, port: number, useTls: boolean): net.Socket { + return useTls + ? armGameKeepAlive(tls.connect({ host, port, servername: host, rejectUnauthorized: false })) + : armGameKeepAlive(net.connect(port, host)); +} diff --git a/proxy/server.ts b/proxy/server.ts index 3ad498a3..e864badf 100644 --- a/proxy/server.ts +++ b/proxy/server.ts @@ -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 { connectGame } from './gameSocket'; const PORT = parseInt(process.env.PORT ?? '3001', 10); @@ -292,7 +293,8 @@ wss.on('connection', (ws, req) => { let tcpConnected = false; 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). + const secure = connectGame(host, port, true) as tls.TLSSocket; secure.on('secureConnect', () => { tcpConnected = true; const cert = secure.getPeerCertificate(); @@ -326,7 +328,7 @@ wss.on('connection', (ws, req) => { }); tcp = secure; } else { - tcp = net.connect(port, host); + tcp = connectGame(host, port, false); tcp.on('connect', () => { tcpConnected = true; console.log(`[proxy] Connected to ${host}:${port}`); diff --git a/src/mud/connection/MudClient.ts b/src/mud/connection/MudClient.ts index c340ec6f..14e159f6 100644 --- a/src/mud/connection/MudClient.ts +++ b/src/mud/connection/MudClient.ts @@ -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 | 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(); @@ -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; @@ -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(); @@ -759,6 +771,7 @@ export class MudClient { this.pendingTelnet = ""; this.negotiator.clearMspNegotiated(); this.cancelCharacterModeDetection(); + this.stopIdleProbe(); }; this.socket.onopen = (event: Event) => { @@ -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'); } @@ -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 @@ -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}. */ diff --git a/tests/mud/connection/idleProbe.test.ts b/tests/mud/connection/idleProbe.test.ts new file mode 100644 index 00000000..c8521d57 --- /dev/null +++ b/tests/mud/connection/idleProbe.test.ts @@ -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).WebSocket; + realAddEventListener = (globalThis as Record).addEventListener; + (globalThis as Record).WebSocket = MockWebSocket as unknown; + (globalThis as Record).addEventListener = () => {}; + MockWebSocket.instances = []; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date', 'performance'] }); + }); + afterEach(() => { + vi.useRealTimers(); + (globalThis as Record).WebSocket = realWebSocket; + (globalThis as Record).addEventListener = realAddEventListener; + }); + + function open(url = DIRECT_URL) { + const bus = new EventBus(); + 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); + }); +}); diff --git a/tests/proxy/gameKeepAlive.test.ts b/tests/proxy/gameKeepAlive.test.ts new file mode 100644 index 00000000..8cce8c4a --- /dev/null +++ b/tests/proxy/gameKeepAlive.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as net from 'net'; +import * as tls from 'tls'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { connectGame, GAME_KEEPALIVE_DELAY_MS } from '../../proxy/gameSocket'; + +/** Issue #454: a game host that dies without closing left the proxy's socket to + * it half-open for good, so the browser never saw a disconnect. Keepalive on + * that socket is what turns the silence into an error and a close. */ +describe('proxy game socket keepalive (issue #454)', () => { + const cleanup: (() => void)[] = []; + afterEach(() => { + while (cleanup.length) cleanup.pop()!(); + vi.restoreAllMocks(); + }); + + async function listen(): Promise { + const server = net.createServer(sock => { cleanup.push(() => sock.destroy()); }); + cleanup.push(() => server.close()); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + return (server.address() as net.AddressInfo).port; + } + + it.each([ + ['plain', false, net.Socket], + ['TLS', true, tls.TLSSocket], + ] as const)('arms keepalive on the %s game socket', async (_label, useTls, kind) => { + const port = await listen(); + const setKeepAlive = vi.spyOn(net.Socket.prototype, 'setKeepAlive'); + const socket = connectGame('127.0.0.1', port, useTls); + // A TLS dial to a plain listener fails its handshake; that's fine here. + socket.on('error', () => {}); + cleanup.push(() => socket.destroy()); + + expect(socket).toBeInstanceOf(kind); + expect(setKeepAlive).toHaveBeenCalledWith(true, GAME_KEEPALIVE_DELAY_MS); + expect(setKeepAlive.mock.contexts).toContain(socket); + }); + + it('starts probing after a minute of silence, as desktop does within minutes', () => { + expect(GAME_KEEPALIVE_DELAY_MS).toBe(60_000); + }); + + it('server.ts dials the game only through connectGame', () => { + const source = readFileSync(resolve(__dirname, '../../proxy/server.ts'), 'utf8'); + expect(source).not.toMatch(/\bnet\.connect\(/); + expect(source).not.toMatch(/\btls\.connect\(/); + expect(source.match(/connectGame\(/g)?.length).toBe(2); + }); +}); From 32e2f313fad4f230c6cc9507d16b01b26cec9b22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:49:00 +0000 Subject: [PATCH 2/2] Keep the proxy's TLS dial in server.ts so CodeQL doesn't see it as new Moving tls.connect({ rejectUnauthorized: false }) into gameSocket.ts made CodeQL report the existing, deliberate setting (the cert is checked by hand in certProblems) as a new high alert. Leave the dial where it was and arm keepalive on the socket instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Um3ZSHRbGj1Rbf55rAQzdM --- proxy/gameSocket.ts | 10 ---------- proxy/server.ts | 7 ++++--- tests/proxy/gameKeepAlive.test.ts | 15 +++++++++------ 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/proxy/gameSocket.ts b/proxy/gameSocket.ts index 90b8fd4d..8b73f5be 100644 --- a/proxy/gameSocket.ts +++ b/proxy/gameSocket.ts @@ -1,5 +1,4 @@ import * as net from 'net'; -import * as tls from 'tls'; /** 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 @@ -18,12 +17,3 @@ export function armGameKeepAlive(socket: T): T { socket.setKeepAlive(true, GAME_KEEPALIVE_DELAY_MS); return socket; } - -/** Dial the game, plain or over TLS, with keepalive on either way. TLS - * verification is left to the caller (it inspects the cert itself), hence - * `rejectUnauthorized: false`. */ -export function connectGame(host: string, port: number, useTls: boolean): net.Socket { - return useTls - ? armGameKeepAlive(tls.connect({ host, port, servername: host, rejectUnauthorized: false })) - : armGameKeepAlive(net.connect(port, host)); -} diff --git a/proxy/server.ts b/proxy/server.ts index e864badf..234261e0 100644 --- a/proxy/server.ts +++ b/proxy/server.ts @@ -2,7 +2,7 @@ import * as http from 'http'; import * as net from 'net'; import * as tls from 'tls'; import { WebSocketServer, WebSocket } from 'ws'; -import { connectGame } from './gameSocket'; +import { armGameKeepAlive } from './gameSocket'; const PORT = parseInt(process.env.PORT ?? '3001', 10); @@ -293,8 +293,9 @@ wss.on('connection', (ws, req) => { let tcpConnected = false; 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). - const secure = connectGame(host, port, true) as tls.TLSSocket; + armGameKeepAlive(secure); secure.on('secureConnect', () => { tcpConnected = true; const cert = secure.getPeerCertificate(); @@ -328,7 +329,7 @@ wss.on('connection', (ws, req) => { }); tcp = secure; } else { - tcp = connectGame(host, port, false); + tcp = armGameKeepAlive(net.connect(port, host)); tcp.on('connect', () => { tcpConnected = true; console.log(`[proxy] Connected to ${host}:${port}`); diff --git a/tests/proxy/gameKeepAlive.test.ts b/tests/proxy/gameKeepAlive.test.ts index 8cce8c4a..7663af60 100644 --- a/tests/proxy/gameKeepAlive.test.ts +++ b/tests/proxy/gameKeepAlive.test.ts @@ -4,7 +4,7 @@ import * as net from 'net'; import * as tls from 'tls'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -import { connectGame, GAME_KEEPALIVE_DELAY_MS } from '../../proxy/gameSocket'; +import { armGameKeepAlive, GAME_KEEPALIVE_DELAY_MS } from '../../proxy/gameSocket'; /** Issue #454: a game host that dies without closing left the proxy's socket to * it half-open for good, so the browser never saw a disconnect. Keepalive on @@ -29,7 +29,9 @@ describe('proxy game socket keepalive (issue #454)', () => { ] as const)('arms keepalive on the %s game socket', async (_label, useTls, kind) => { const port = await listen(); const setKeepAlive = vi.spyOn(net.Socket.prototype, 'setKeepAlive'); - const socket = connectGame('127.0.0.1', port, useTls); + const socket = armGameKeepAlive(useTls + ? tls.connect({ host: '127.0.0.1', port }) + : net.connect(port, '127.0.0.1')); // A TLS dial to a plain listener fails its handshake; that's fine here. socket.on('error', () => {}); cleanup.push(() => socket.destroy()); @@ -43,10 +45,11 @@ describe('proxy game socket keepalive (issue #454)', () => { expect(GAME_KEEPALIVE_DELAY_MS).toBe(60_000); }); - it('server.ts dials the game only through connectGame', () => { + it('server.ts arms keepalive on both of its game dials', () => { const source = readFileSync(resolve(__dirname, '../../proxy/server.ts'), 'utf8'); - expect(source).not.toMatch(/\bnet\.connect\(/); - expect(source).not.toMatch(/\btls\.connect\(/); - expect(source.match(/connectGame\(/g)?.length).toBe(2); + expect(source.match(/\bnet\.connect\(/g)?.length).toBe(1); + expect(source.match(/\btls\.connect\(/g)?.length).toBe(1); + expect(source).toMatch(/armGameKeepAlive\(net\.connect\(/); + expect(source).toMatch(/armGameKeepAlive\(secure\)/); }); });