From d920fa7a1b61571d8a1895648a54a3ba9983de3e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 11 Nov 2025 14:09:07 +0100 Subject: [PATCH 01/58] add permissions to publish workflow --- .github/workflows/release.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 55f9b8ff62..7b1d7c6a14 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,6 +8,11 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} +permissions: + id-token: write # Required for OIDC + contents: write # Required to create GH releases + pull-requests: write # Required to interact with PRs + jobs: release: name: Release From 3f6c42879c6c71f31f39ee2f9b0f15c470519649 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 09:21:20 +0100 Subject: [PATCH 02/58] wip --- .github/workflows/test.yaml | 8 +-- package.json | 1 + pnpm-lock.yaml | 19 ++++-- src/api/SignalClient.ts | 73 ++++++++++++--------- src/api/WebSocketStream.test.ts | 112 ++++++++++++++++++++------------ src/api/WebSocketStream.ts | 90 +++++++++++++++---------- 6 files changed, 189 insertions(+), 114 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 891e2ef335..4432c2f89a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,12 +9,12 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - name: Use Node.js 24 - uses: actions/setup-node@v6 + - name: Use Node.js 20 + uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 20 cache: 'pnpm' - name: Install dependencies diff --git a/package.json b/package.json index 545e88ca9e..1bd0f0a0fa 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", + "neverthrow": "^8.2.0", "sdp-transform": "^2.15.0", "ts-debounce": "^4.0.0", "tslib": "2.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c83d3bb32a..73afecd77b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: loglevel: specifier: ^1.9.2 version: 1.9.2 + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 sdp-transform: specifier: ^2.15.0 version: 2.15.0 @@ -2776,6 +2779,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neverthrow@8.2.0: + resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} + engines: {node: '>=18'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3440,8 +3447,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20251110: - resolution: {integrity: sha512-tHG+EJXTSaUCMbTNApOuVE3WmgOmEqUwQiAXnmwsF/sVKhPFHQA0+S1hml0Ro8kpayvD0d9AX5iC2S2s+TIQxQ==} + typescript@6.0.0-dev.20251111: + resolution: {integrity: sha512-335S5mZVzEbVf9M3jhodtFDg/85L0LVZo5oNWLs6sUJ/JIpd0HASHJsD2brvWAWN0kFOhVT9LrJ5C9Jq8XsCaA==} engines: {node: '>=14.17'} hasBin: true @@ -5662,7 +5669,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20251110 + typescript: 6.0.0-dev.20251111 dunder-proto@1.0.1: dependencies: @@ -6757,6 +6764,10 @@ snapshots: neo-async@2.6.2: {} + neverthrow@8.2.0: + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.53.2 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -7486,7 +7497,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20251110: {} + typescript@6.0.0-dev.20251111: {} uc.micro@2.1.0: {} diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 07f21c6737..bb1ba224a4 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -360,54 +360,63 @@ export class SignalClient { this.ws = new WebSocketStream(rtcUrl); try { - this.ws.closed - .then((closeInfo) => { + this.ws.closed.then((result) => { + if (result.isErr()) { + const errorMessage = + result.error.type === 'unspecified' + ? result.error.message + : 'websocket error event'; if (this.isEstablishingConnection) { reject( new ConnectionError( - `Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`, + `Websocket error during a (re)connection attempt: ${errorMessage}`, ConnectionErrorReason.InternalError, ), ); } - if (closeInfo.closeCode !== 1000) { - this.log.warn(`websocket closed`, { - ...this.logContext, - reason: closeInfo.reason, - code: closeInfo.closeCode, - wasClean: closeInfo.closeCode === 1000, - state: this.state, - }); - } return; - }) - .catch((reason) => { - if (this.isEstablishingConnection) { - reject( - new ConnectionError( - `Websocket error during a (re)connection attempt: ${reason}`, - ConnectionErrorReason.InternalError, - ), - ); - } - }); - const connection = await this.ws.opened.catch(async (reason: unknown) => { + } + + const closeInfo = result.value; + if (this.isEstablishingConnection) { + reject( + new ConnectionError( + `Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`, + ConnectionErrorReason.InternalError, + ), + ); + } + if (closeInfo.closeCode !== 1000) { + this.log.warn(`websocket closed`, { + ...this.logContext, + reason: closeInfo.reason, + code: closeInfo.closeCode, + wasClean: closeInfo.closeCode === 1000, + state: this.state, + }); + } + }); + const result = await this.ws.opened; + clearTimeout(wsTimeout); + + if (result.isErr()) { if (this.state !== SignalConnectionState.CONNECTED) { this.state = SignalConnectionState.DISCONNECTED; - clearTimeout(wsTimeout); - const error = await this.handleConnectionError(reason, validateUrl); + const errorMessage = + result.error.type === 'abort' + ? result.error.message + : 'websocket connection error'; + const error = await this.handleConnectionError(errorMessage, validateUrl); reject(error); return; } // other errors, handle - this.handleWSError(reason); - reject(reason); - return; - }); - clearTimeout(wsTimeout); - if (!connection) { + this.handleWSError(result.error); + reject(result.error); return; } + + const connection = result.value; const signalReader = connection.readable.getReader(); this.streamWriter = connection.writable.getWriter(); const firstMessage = await signalReader.read(); diff --git a/src/api/WebSocketStream.test.ts b/src/api/WebSocketStream.test.ts index 3445348042..b47920545b 100644 --- a/src/api/WebSocketStream.test.ts +++ b/src/api/WebSocketStream.test.ts @@ -122,6 +122,16 @@ vi.mock('../room/utils', () => ({ sleep: vi.fn((duration: number) => new Promise((resolve) => setTimeout(resolve, duration))), })); +// Helper function to unwrap Result from opened promise +async function getConnectionOrFail(wsStream: WebSocketStream) { + const result = await wsStream.opened; + expect(result.isOk()).toBe(true); + if (!result.isOk()) { + throw new Error('Failed to open connection'); + } + return result.value; +} + describe('WebSocketStream', () => { let mockWebSocket: MockWebSocket; let originalWebSocket: typeof WebSocket; @@ -201,21 +211,29 @@ describe('WebSocketStream', () => { const removeEventListenerSpy = vi.spyOn(mockWebSocket, 'removeEventListener'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; - - expect(connection.readable).toBeInstanceOf(ReadableStream); - expect(connection.writable).toBeInstanceOf(WritableStream); - expect(connection.protocol).toBe('test-protocol'); - expect(connection.extensions).toBe('test-extension'); + const result = await wsStream.opened; + + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const connection = result.value; + expect(connection.readable).toBeInstanceOf(ReadableStream); + expect(connection.writable).toBeInstanceOf(WritableStream); + expect(connection.protocol).toBe('test-protocol'); + expect(connection.extensions).toBe('test-extension'); + } expect(removeEventListenerSpy).toHaveBeenCalledWith('error', expect.any(Function)); }); - it('should reject when WebSocket errors before opening', async () => { + it('should return error Result when WebSocket errors before opening', async () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerError(); - await expect(wsStream.opened).rejects.toThrow(); + const result = await wsStream.opened; + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.type).toBe('connection'); + } }); }); @@ -227,10 +245,13 @@ describe('WebSocketStream', () => { mockWebSocket.triggerOpen(); mockWebSocket.triggerClose(1001, 'Going away'); - const closeInfo = await wsStream.closed; + const result = await wsStream.closed; - expect(closeInfo.closeCode).toBe(1001); - expect(closeInfo.reason).toBe('Going away'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.closeCode).toBe(1001); + expect(result.value.reason).toBe('Going away'); + } expect(removeEventListenerSpy).toHaveBeenCalledWith('error', expect.any(Function)); }); @@ -241,13 +262,16 @@ describe('WebSocketStream', () => { mockWebSocket.triggerError(); mockWebSocket.triggerClose(1006, 'Connection failed'); - const closeInfo = await wsStream.closed; + const result = await wsStream.closed; - expect(closeInfo.closeCode).toBe(1006); - expect(closeInfo.reason).toBe('Connection failed'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.closeCode).toBe(1006); + expect(result.value.reason).toBe('Connection failed'); + } }); - it('should reject when error occurs without timely close event', async () => { + it('should return error Result when error occurs without timely close event', async () => { const { sleep } = await import('../room/utils'); vi.mocked(sleep).mockResolvedValue(undefined); @@ -256,9 +280,14 @@ describe('WebSocketStream', () => { mockWebSocket.triggerOpen(); mockWebSocket.triggerError(); - await expect(wsStream.closed).rejects.toThrow( - 'Encountered unspecified websocket error without a timely close event', - ); + const result = await wsStream.closed; + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.type).toBe('unspecified'); + expect(result.error.message).toBe( + 'Encountered unspecified websocket error without a timely close event', + ); + } }); }); @@ -267,8 +296,11 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const result = await wsStream.opened; + expect(result.isOk()).toBe(true); + if (!result.isOk()) return; + const connection = result.value; const reader = connection.readable.getReader(); const message1 = new ArrayBuffer(8); @@ -292,23 +324,22 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); mockWebSocket.triggerError(); - await Promise.all([ - expect(reader.read()).rejects.toBeDefined(), - expect(wsStream.closed).rejects.toBeDefined(), - ]); + const closedResult = await wsStream.closed; + await expect(reader.read()).rejects.toBeDefined(); + expect(closedResult.isErr()).toBe(true); }); it('should close WebSocket with custom close info when cancelled', async () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); const closeSpy = vi.spyOn(mockWebSocket, 'close'); @@ -322,7 +353,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader1 = connection.readable.getReader(); @@ -337,7 +368,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const writer = connection.writable.getWriter(); const sendSpy = vi.spyOn(mockWebSocket, 'send'); @@ -362,7 +393,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const writer = connection.writable.getWriter(); const closeSpy = vi.spyOn(mockWebSocket, 'close'); @@ -376,7 +407,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const writer = connection.writable.getWriter(); @@ -418,7 +449,7 @@ describe('WebSocketStream', () => { }); mockWebSocket.triggerOpen(); - await wsStream.opened; + await getConnectionOrFail(wsStream); const closeSpy = vi.spyOn(mockWebSocket, 'close'); @@ -433,7 +464,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); @@ -467,7 +498,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); const writer = connection.writable.getWriter(); @@ -493,7 +524,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const sourceData = [new ArrayBuffer(8), new ArrayBuffer(16), new ArrayBuffer(32)]; let dataIndex = 0; @@ -524,7 +555,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const msg1 = new ArrayBuffer(8); const msg2 = new ArrayBuffer(16); @@ -552,7 +583,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); @@ -562,17 +593,16 @@ describe('WebSocketStream', () => { // Trigger error while read is pending mockWebSocket.triggerError(); - await Promise.all([ - expect(readPromise).rejects.toBeDefined(), - expect(wsStream.closed).rejects.toBeDefined(), - ]); + const closedResult = await wsStream.closed; + await expect(readPromise).rejects.toBeDefined(); + expect(closedResult.isErr()).toBe(true); }); it('should support zero-length and empty messages', async () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); const writer = connection.writable.getWriter(); @@ -599,7 +629,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index d930c212b9..ee0671b21c 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -1,4 +1,5 @@ // https://github.com/CarterLi/websocketstream-polyfill +import { Result, err, ok } from 'neverthrow'; import { sleep } from '../room/utils'; export interface WebSocketConnection { @@ -13,6 +14,14 @@ export interface WebSocketCloseInfo { reason?: string; } +export type WebSocketOpenError = + | { type: 'abort'; message: string } + | { type: 'connection'; error: Event }; + +export type WebSocketCloseError = + | { type: 'unspecified'; message: string } + | { type: 'error'; error: Event }; + export interface WebSocketStreamOptions { protocols?: string[]; signal?: AbortSignal; @@ -26,9 +35,9 @@ export interface WebSocketStreamOptions { export class WebSocketStream { readonly url: string; - readonly opened: Promise>; + readonly opened: Promise, WebSocketOpenError>>; - readonly closed: Promise; + readonly closed: Promise>; readonly close: (closeInfo?: WebSocketCloseInfo) => void; @@ -52,35 +61,44 @@ export class WebSocketStream ws.close(code, reason); - this.opened = new Promise((resolve, reject) => { - ws.onopen = () => { - resolve({ - readable: new ReadableStream({ - start(controller) { - ws.onmessage = ({ data }) => controller.enqueue(data); - ws.onerror = (e) => controller.error(e); - }, - cancel: closeWithInfo, - }), - writable: new WritableStream({ - write(chunk) { - ws.send(chunk); - }, - abort() { - ws.close(); - }, - close: closeWithInfo, + this.opened = new Promise((resolve) => { + const errorHandler = (e: Event) => { + resolve(err({ type: 'connection', error: e })); + ws.removeEventListener('open', openHandler); + }; + + const openHandler = () => { + resolve( + ok({ + readable: new ReadableStream({ + start(controller) { + ws.onmessage = ({ data }) => controller.enqueue(data); + ws.onerror = (e) => controller.error(e); + }, + cancel: closeWithInfo, + }), + writable: new WritableStream({ + write(chunk) { + ws.send(chunk); + }, + abort() { + ws.close(); + }, + close: closeWithInfo, + }), + protocol: ws.protocol, + extensions: ws.extensions, }), - protocol: ws.protocol, - extensions: ws.extensions, - }); - ws.removeEventListener('error', reject); + ); + ws.removeEventListener('error', errorHandler); }; - ws.addEventListener('error', reject); + + ws.addEventListener('open', openHandler, { once: true }); + ws.addEventListener('error', errorHandler, { once: true }); }); - this.closed = new Promise((resolve, reject) => { - const rejectHandler = async () => { + this.closed = new Promise>((resolve) => { + const errorHandler = async () => { const closePromise = new Promise((res) => { if (ws.readyState === WebSocket.CLOSED) return; else { @@ -95,18 +113,24 @@ export class WebSocketStream { - resolve({ closeCode: code, reason }); - ws.removeEventListener('error', rejectHandler); + resolve(ok({ closeCode: code, reason })); + ws.removeEventListener('error', errorHandler); }; - ws.addEventListener('error', rejectHandler); + ws.addEventListener('error', errorHandler); }); if (options.signal) { From f38441e389dc6b16058c8ec73d905cb7492c1baa Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 14:04:48 +0100 Subject: [PATCH 03/58] Make WebsocketStream use Result type --- .eslintrc.cjs | 2 + package.json | 1 + pnpm-lock.yaml | 69 ++++++++++++- src/api/SignalClient.test.ts | 96 +++++++++++++++--- src/api/SignalClient.ts | 171 ++++++++++++++++---------------- src/api/WebSocketStream.test.ts | 2 +- src/api/WebSocketStream.ts | 112 +++++++++++---------- 7 files changed, 297 insertions(+), 156 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d118e9ede4..1c07c6e39b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,6 +4,7 @@ module.exports = { parserOptions: { project: './tsconfig.eslint.json', }, + plugins: ['neverthrow'], rules: { 'import/export': 'off', 'max-classes-per-file': 'off', @@ -14,5 +15,6 @@ module.exports = { 'class-methods-use-this': 'off', 'no-underscore-dangle': 'off', '@typescript-eslint/no-use-before-define': 'off', + 'neverthrow/must-use-result': 'error', }, }; diff --git a/package.json b/package.json index 1bd0f0a0fa..09dc5931a9 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "eslint-config-prettier": "10.1.8", "eslint-plugin-ecmascript-compat": "^3.2.1", "eslint-plugin-import": "2.32.0", + "eslint-plugin-neverthrow": "^1.1.4", "gh-pages": "6.3.0", "happy-dom": "^17.2.0", "jsdom": "^26.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73afecd77b..2fec392847 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: eslint-plugin-import: specifier: 2.32.0 version: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1) + eslint-plugin-neverthrow: + specifier: ^1.1.4 + version: 1.1.4(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) gh-pages: specifier: 6.3.0 version: 6.3.0 @@ -1299,6 +1302,9 @@ packages: '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + '@types/eslint-utils@3.0.5': + resolution: {integrity: sha512-dGOLJqHXpjomkPgZiC7vnVSJtFIOM1Y6L01EyUhzPuD0y0wfIGiqxiGs3buUBfzxLIQHrCvZsIMDaCZ8R5IIoA==} + '@types/eslint@8.44.7': resolution: {integrity: sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==} @@ -2041,6 +2047,13 @@ packages: '@typescript-eslint/parser': optional: true + eslint-plugin-neverthrow@1.1.4: + resolution: {integrity: sha512-+8zsE5rDqsDfKYAOq0Fr2jbuxHXTmntIWWJqJA3ms1GAKcVCjl0ycetzOu/hTxot9ctr+WYQpCBgB3F2HATR7A==} + engines: {node: '>=14.17'} + peerDependencies: + '@typescript-eslint/parser': '>=4.20.0' + eslint: '>=5.16.0' + eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -2049,6 +2062,16 @@ packages: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-utils@3.0.0: + resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} + engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} + peerDependencies: + eslint: '>=5' + + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3379,9 +3402,18 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3447,8 +3479,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20251111: - resolution: {integrity: sha512-335S5mZVzEbVf9M3jhodtFDg/85L0LVZo5oNWLs6sUJ/JIpd0HASHJsD2brvWAWN0kFOhVT9LrJ5C9Jq8XsCaA==} + typescript@6.0.0-dev.20251112: + resolution: {integrity: sha512-t9fwupCY/vxrHyZR+m3BOwvXdnpuq9Yp0I8ZWFneE5PICANlM4Qgfps3prhzhtiFXPMxfBy5+UEFkynOFybIlg==} engines: {node: '>=14.17'} hasBin: true @@ -5021,6 +5053,11 @@ snapshots: '@types/eslint': 8.44.7 '@types/estree': 1.0.8 + '@types/eslint-utils@3.0.5': + dependencies: + '@types/eslint': 8.44.7 + '@types/estree': 1.0.8 + '@types/eslint@8.44.7': dependencies: '@types/estree': 1.0.8 @@ -5669,7 +5706,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20251111 + typescript: 6.0.0-dev.20251112 dunder-proto@1.0.1: dependencies: @@ -5983,6 +6020,16 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-neverthrow@1.1.4(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3): + dependencies: + '@types/eslint-utils': 3.0.5 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + eslint-utils: 3.0.0(eslint@8.57.1) + tsutils: 3.21.0(typescript@5.8.3) + transitivePeerDependencies: + - typescript + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -5993,6 +6040,13 @@ snapshots: esrecurse: 4.3.0 estraverse: 5.3.0 + eslint-utils@3.0.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 2.1.0 + + eslint-visitor-keys@2.1.0: {} + eslint-visitor-keys@3.4.3: {} eslint@8.57.1: @@ -7403,8 +7457,15 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} + tsutils@3.21.0(typescript@5.8.3): + dependencies: + tslib: 1.14.1 + typescript: 5.8.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7497,7 +7558,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20251111: {} + typescript@6.0.0-dev.20251112: {} uc.micro@2.1.0: {} diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index 0359a50295..caa440f3e6 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -5,10 +5,16 @@ import { SignalRequest, SignalResponse, } from '@livekit/protocol'; +import { ResultAsync } from 'neverthrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionError, ConnectionErrorReason } from '../room/errors'; import { SignalClient, SignalConnectionState } from './SignalClient'; -import type { WebSocketCloseInfo, WebSocketConnection } from './WebSocketStream'; +import type { + WebSocketCloseError, + WebSocketCloseInfo, + WebSocketConnection, + WebSocketOpenError, +} from './WebSocketStream'; import { WebSocketStream } from './WebSocketStream'; // Mock the WebSocketStream @@ -57,16 +63,30 @@ function createMockConnection(readable: ReadableStream): WebSocketC interface MockWebSocketStreamOptions { connection?: WebSocketConnection; - opened?: Promise; - closed?: Promise; + opened?: ResultAsync, WebSocketOpenError>; + closed?: ResultAsync; readyState?: number; } function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) { const { connection, - opened = connection ? Promise.resolve(connection) : new Promise(() => {}), - closed = new Promise(() => {}), + // eslint-disable-next-line neverthrow/must-use-result + opened = connection + ? ResultAsync.fromPromise( + Promise.resolve(connection), + (error) => ({ type: 'connection' as const, error: error as Event }), + ) + : // eslint-disable-next-line neverthrow/must-use-result + ResultAsync.fromPromise( + new Promise(() => {}), + (error) => ({ type: 'connection' as const, error: error as Event }), + ), + // eslint-disable-next-line neverthrow/must-use-result + closed = ResultAsync.fromPromise( + new Promise(() => {}), + (error) => error as WebSocketCloseError, + ), readyState = 1, } = options; @@ -191,10 +211,21 @@ describe('SignalClient.connect', () => { // Simulate abort setTimeout(() => abortController.abort(new Error('User aborted connection')), 50); + // eslint-disable-next-line neverthrow/must-use-result + const opened = ResultAsync.fromPromise( + new Promise(() => {}), + (error) => ({ type: 'connection' as const, error: error as Event }), + ); + // eslint-disable-next-line neverthrow/must-use-result + const closed = ResultAsync.fromPromise( + new Promise(() => {}), + (error) => error as WebSocketCloseError, + ); + return { url: 'wss://test.livekit.io', - opened: new Promise(() => {}), // Never resolves - closed: new Promise(() => {}), + opened, + closed, close: vi.fn(), readyState: 0, } as any; @@ -249,10 +280,21 @@ describe('SignalClient.connect', () => { }; vi.mocked(WebSocketStream).mockImplementation(() => { + // eslint-disable-next-line neverthrow/must-use-result + const opened = ResultAsync.fromPromise( + Promise.resolve(mockConnection), + (error) => ({ type: 'connection' as const, error: error as Event }), + ); + // eslint-disable-next-line neverthrow/must-use-result + const closed = ResultAsync.fromPromise( + new Promise(() => {}), + (error) => error as WebSocketCloseError, + ); + return { url: 'wss://test.livekit.io', - opened: Promise.resolve(mockConnection), - closed: new Promise(() => {}), + opened, + closed, close: vi.fn(), readyState: 1, } as any; @@ -296,8 +338,13 @@ describe('SignalClient.connect', () => { describe('Failure Case - WebSocket Connection Errors', () => { it('should reject with NotAllowed error for 4xx HTTP status', async () => { + // eslint-disable-next-line neverthrow/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(new Error('Connection failed')), + (error) => ({ type: 'connection' as const, error: error as Event }), + ); mockWebSocketStream({ - opened: Promise.reject(new Error('Connection failed')), + opened, readyState: 3, }); @@ -317,8 +364,13 @@ describe('SignalClient.connect', () => { }); it('should reject with ServerUnreachable when fetch fails', async () => { + // eslint-disable-next-line neverthrow/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(new Error('Connection failed')), + (error) => ({ type: 'connection' as const, error: error as Event }), + ); mockWebSocketStream({ - opened: Promise.reject(new Error('Connection failed')), + opened, readyState: 3, }); @@ -339,8 +391,13 @@ describe('SignalClient.connect', () => { 500, ); + // eslint-disable-next-line neverthrow/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(customError), + (error) => ({ type: 'connection' as const, error: error as Event }), + ); mockWebSocketStream({ - opened: Promise.reject(customError), + opened, readyState: 3, }); @@ -437,10 +494,21 @@ describe('SignalClient.connect', () => { closedResolve({ closeCode: 1006, reason: 'Connection lost' }); }); + // eslint-disable-next-line neverthrow/must-use-result + const opened = ResultAsync.fromPromise( + new Promise(() => {}), + (error) => ({ type: 'connection' as const, error: error as Event }), + ); + // eslint-disable-next-line neverthrow/must-use-result + const closed = ResultAsync.fromPromise( + closedPromise, + (error) => error as WebSocketCloseError, + ); + return { url: 'wss://test.livekit.io', - opened: new Promise(() => {}), // Never resolves - closed: closedPromise, + opened, + closed, close: vi.fn(), readyState: 2, // CLOSING } as any; diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index bb1ba224a4..1cf3ce69ae 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -359,25 +359,8 @@ export class SignalClient { } this.ws = new WebSocketStream(rtcUrl); - try { - this.ws.closed.then((result) => { - if (result.isErr()) { - const errorMessage = - result.error.type === 'unspecified' - ? result.error.message - : 'websocket error event'; - if (this.isEstablishingConnection) { - reject( - new ConnectionError( - `Websocket error during a (re)connection attempt: ${errorMessage}`, - ConnectionErrorReason.InternalError, - ), - ); - } - return; - } - - const closeInfo = result.value; + this.ws.closed.match( + (closeInfo) => { if (this.isEstablishingConnection) { reject( new ConnectionError( @@ -395,74 +378,93 @@ export class SignalClient { state: this.state, }); } - }); - const result = await this.ws.opened; - clearTimeout(wsTimeout); - - if (result.isErr()) { - if (this.state !== SignalConnectionState.CONNECTED) { - this.state = SignalConnectionState.DISCONNECTED; - const errorMessage = - result.error.type === 'abort' - ? result.error.message - : 'websocket connection error'; - const error = await this.handleConnectionError(errorMessage, validateUrl); - reject(error); - return; + }, + (error) => { + const errorMessage = + error.type === 'unspecified' ? error.message : 'websocket error event'; + if (this.isEstablishingConnection) { + reject( + new ConnectionError( + `Websocket error during a (re)connection attempt: ${errorMessage}`, + ConnectionErrorReason.InternalError, + ), + ); } - // other errors, handle - this.handleWSError(result.error); - reject(result.error); - return; - } + }, + ); - const connection = result.value; - const signalReader = connection.readable.getReader(); - this.streamWriter = connection.writable.getWriter(); - const firstMessage = await signalReader.read(); - signalReader.releaseLock(); - if (!firstMessage.value) { - throw new ConnectionError( - 'no message received as first message', - ConnectionErrorReason.InternalError, - ); - } + try { + const result = await this.ws.opened; + clearTimeout(wsTimeout); - const firstSignalResponse = parseSignalResponse(firstMessage.value); + await result.match( + async (connection) => { + try { + const signalReader = connection.readable.getReader(); + this.streamWriter = connection.writable.getWriter(); + const firstMessage = await signalReader.read(); + signalReader.releaseLock(); + if (!firstMessage.value) { + reject( + new ConnectionError( + 'no message received as first message', + ConnectionErrorReason.InternalError, + ), + ); + return; + } + + const firstSignalResponse = parseSignalResponse(firstMessage.value); + + // Validate the first message + const validation = this.validateFirstMessage( + firstSignalResponse, + opts.reconnect ?? false, + ); - // Validate the first message - const validation = this.validateFirstMessage( - firstSignalResponse, - opts.reconnect ?? false, + if (!validation.isValid) { + reject(validation.error); + return; + } + + // Handle join response - set up ping configuration + if (firstSignalResponse.message?.case === 'join') { + this.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; + this.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; + + if (this.pingTimeoutDuration && this.pingTimeoutDuration > 0) { + this.log.debug('ping config', { + ...this.logContext, + timeout: this.pingTimeoutDuration, + interval: this.pingIntervalDuration, + }); + } + } + + // Handle successful connection + const firstMessageToProcess = validation.shouldProcessFirstMessage + ? firstSignalResponse + : undefined; + handleSignalConnected(connection, firstMessageToProcess); + resolve(validation.response); + } catch (e) { + reject(e); + } + }, + async (error) => { + if (this.state !== SignalConnectionState.CONNECTED) { + this.state = SignalConnectionState.DISCONNECTED; + const errorMessage = + error.type === 'abort' ? error.message : 'websocket connection error'; + const connectionError = await this.handleConnectionError(errorMessage, validateUrl); + reject(connectionError); + return; + } + // other errors, handle + this.handleWSError(error); + reject(error); + }, ); - - if (!validation.isValid) { - reject(validation.error); - return; - } - - // Handle join response - set up ping configuration - if (firstSignalResponse.message?.case === 'join') { - this.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; - this.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; - - if (this.pingTimeoutDuration && this.pingTimeoutDuration > 0) { - this.log.debug('ping config', { - ...this.logContext, - timeout: this.pingTimeoutDuration, - interval: this.pingIntervalDuration, - }); - } - } - - // Handle successful connection - const firstMessageToProcess = validation.shouldProcessFirstMessage - ? firstSignalResponse - : undefined; - handleSignalConnected(connection, firstMessageToProcess); - resolve(validation.response); - } catch (e) { - reject(e); } finally { cleanupAbortHandlers(); } @@ -519,7 +521,10 @@ export class SignalClient { this.ws.close({ closeCode: 1000, reason }); // calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED - const closePromise = this.ws.closed; + const closePromise = this.ws.closed.match( + (closeInfo) => closeInfo, + (error) => error, + ); this.ws = undefined; this.streamWriter = undefined; await Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]); diff --git a/src/api/WebSocketStream.test.ts b/src/api/WebSocketStream.test.ts index b47920545b..f5a0949796 100644 --- a/src/api/WebSocketStream.test.ts +++ b/src/api/WebSocketStream.test.ts @@ -284,7 +284,7 @@ describe('WebSocketStream', () => { expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.type).toBe('unspecified'); - expect(result.error.message).toBe( + expect(result.error.type === 'unspecified' && result.error.message).toBe( 'Encountered unspecified websocket error without a timely close event', ); } diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index ee0671b21c..94d3324d0f 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -1,5 +1,5 @@ // https://github.com/CarterLi/websocketstream-polyfill -import { Result, err, ok } from 'neverthrow'; +import { ResultAsync } from 'neverthrow'; import { sleep } from '../room/utils'; export interface WebSocketConnection { @@ -35,11 +35,11 @@ export interface WebSocketStreamOptions { export class WebSocketStream { readonly url: string; - readonly opened: Promise, WebSocketOpenError>>; + readonly opened: ResultAsync, WebSocketOpenError>; - readonly closed: Promise>; + readonly closed: ResultAsync; - readonly close: (closeInfo?: WebSocketCloseInfo) => void; + readonly close!: (closeInfo?: WebSocketCloseInfo) => void; get readyState(): number { return this.ws.readyState; @@ -61,15 +61,16 @@ export class WebSocketStream ws.close(code, reason); - this.opened = new Promise((resolve) => { - const errorHandler = (e: Event) => { - resolve(err({ type: 'connection', error: e })); - ws.removeEventListener('open', openHandler); - }; + // eslint-disable-next-line neverthrow/must-use-result + this.opened = ResultAsync.fromPromise, WebSocketOpenError>( + new Promise((resolve, reject) => { + const errorHandler = (e: Event) => { + reject(e); + ws.removeEventListener('open', openHandler); + }; - const openHandler = () => { - resolve( - ok({ + const openHandler = () => { + resolve({ readable: new ReadableStream({ start(controller) { ws.onmessage = ({ data }) => controller.enqueue(data); @@ -88,50 +89,53 @@ export class WebSocketStream>((resolve) => { - const errorHandler = async () => { - const closePromise = new Promise((res) => { - if (ws.readyState === WebSocket.CLOSED) return; - else { - ws.addEventListener( - 'close', - (closeEv: CloseEvent) => { - res(closeEv); - }, - { once: true }, - ); - } - }); - const reason = await Promise.race([sleep(250), closePromise]); - if (!reason) { - resolve( - err({ + }); + ws.removeEventListener('error', errorHandler); + }; + + ws.addEventListener('open', openHandler, { once: true }); + ws.addEventListener('error', errorHandler, { once: true }); + }), + (error) => ({ type: 'connection', error: error as Event }), + ); + + // eslint-disable-next-line neverthrow/must-use-result + this.closed = ResultAsync.fromPromise( + new Promise((resolve, reject) => { + const errorHandler = async () => { + const closePromise = new Promise((res) => { + if (ws.readyState === WebSocket.CLOSED) return; + else { + ws.addEventListener( + 'close', + (closeEv: CloseEvent) => { + res(closeEv); + }, + { once: true }, + ); + } + }); + const reason = await Promise.race([sleep(250), closePromise]); + if (!reason) { + reject({ type: 'unspecified', message: 'Encountered unspecified websocket error without a timely close event', - }), - ); - } else { - // if we can infer the close reason from the close event then resolve with ok, we don't need to throw - resolve(ok({ closeCode: reason.code, reason: reason.reason })); - } - }; - - ws.onclose = ({ code, reason }) => { - resolve(ok({ closeCode: code, reason })); - ws.removeEventListener('error', errorHandler); - }; - - ws.addEventListener('error', errorHandler); - }); + }); + } else { + // if we can infer the close reason from the close event then resolve with ok, we don't need to throw + resolve({ closeCode: reason.code, reason: reason.reason }); + } + }; + + ws.onclose = ({ code, reason }) => { + resolve({ closeCode: code, reason }); + ws.removeEventListener('error', errorHandler); + }; + + ws.addEventListener('error', errorHandler); + }), + (error) => error as WebSocketCloseError, + ); if (options.signal) { options.signal.onabort = () => ws.close(); From 969cf0d620e9e66ef135a004127c5fca44b2b6a3 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 19:10:40 +0100 Subject: [PATCH 04/58] signal connection working --- .eslintrc.cjs | 1 - examples/demo/demo.ts | 2 +- src/api/SignalClient.test.ts | 127 +++++----- src/api/SignalClient.ts | 401 +++++++++++++++----------------- src/api/WebSocketStream.test.ts | 6 +- src/api/WebSocketStream.ts | 53 +++-- src/api/utils.ts | 83 +++++++ src/room/errors.ts | 18 ++ 8 files changed, 387 insertions(+), 304 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1c07c6e39b..7967a9c0fb 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -15,6 +15,5 @@ module.exports = { 'class-methods-use-this': 'off', 'no-underscore-dangle': 'off', '@typescript-eslint/no-use-before-define': 'off', - 'neverthrow/must-use-result': 'error', }, }; diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index c0dbd095a5..7232998ee5 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -166,7 +166,7 @@ const appActions = { const room = new Room(roomOptions); startTime = Date.now(); - await room.prepareConnection(url, token); + // await room.prepareConnection(url, token); const prewarmTime = Date.now() - startTime; appendLog(`prewarmed connection in ${prewarmTime}ms`); room.localParticipant.on(ParticipantEvent.LocalTrackCpuConstrained, (track, publication) => { diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index caa440f3e6..97f928ac3b 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -5,16 +5,11 @@ import { SignalRequest, SignalResponse, } from '@livekit/protocol'; -import { ResultAsync } from 'neverthrow'; +import { Result, ResultAsync } from 'neverthrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionError, ConnectionErrorReason } from '../room/errors'; -import { SignalClient, SignalConnectionState } from './SignalClient'; -import type { - WebSocketCloseError, - WebSocketCloseInfo, - WebSocketConnection, - WebSocketOpenError, -} from './WebSocketStream'; +import { SignalClient, SignalConnectionState, ValidationType } from './SignalClient'; +import { WebSocketCloseInfo, WebSocketConnection, WebSocketError } from './WebSocketStream'; import { WebSocketStream } from './WebSocketStream'; // Mock the WebSocketStream @@ -63,8 +58,8 @@ function createMockConnection(readable: ReadableStream): WebSocketC interface MockWebSocketStreamOptions { connection?: WebSocketConnection; - opened?: ResultAsync, WebSocketOpenError>; - closed?: ResultAsync; + opened?: ResultAsync, WebSocketError>; + closed?: ResultAsync; readyState?: number; } @@ -73,20 +68,17 @@ function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) { connection, // eslint-disable-next-line neverthrow/must-use-result opened = connection - ? ResultAsync.fromPromise( - Promise.resolve(connection), - (error) => ({ type: 'connection' as const, error: error as Event }), - ) + ? ResultAsync.fromPromise(Promise.resolve(connection), (error) => ({ + type: 'connection' as const, + error: error as Event, + })) : // eslint-disable-next-line neverthrow/must-use-result - ResultAsync.fromPromise( - new Promise(() => {}), - (error) => ({ type: 'connection' as const, error: error as Event }), - ), + ResultAsync.fromPromise(new Promise(() => {}), (error) => ({ + type: 'connection' as const, + error: error as Event, + })), // eslint-disable-next-line neverthrow/must-use-result - closed = ResultAsync.fromPromise( - new Promise(() => {}), - (error) => error as WebSocketCloseError, - ), + closed = ResultAsync.fromPromise(new Promise(() => {}), (error) => error as WebSocketError), readyState = 1, } = options; @@ -212,14 +204,14 @@ describe('SignalClient.connect', () => { setTimeout(() => abortController.abort(new Error('User aborted connection')), 50); // eslint-disable-next-line neverthrow/must-use-result - const opened = ResultAsync.fromPromise( - new Promise(() => {}), - (error) => ({ type: 'connection' as const, error: error as Event }), - ); + const opened = ResultAsync.fromPromise(new Promise(() => {}), (error) => ({ + type: 'connection' as const, + error: error as Event, + })); // eslint-disable-next-line neverthrow/must-use-result const closed = ResultAsync.fromPromise( new Promise(() => {}), - (error) => error as WebSocketCloseError, + (error) => error as WebSocketError, ); return { @@ -281,14 +273,14 @@ describe('SignalClient.connect', () => { vi.mocked(WebSocketStream).mockImplementation(() => { // eslint-disable-next-line neverthrow/must-use-result - const opened = ResultAsync.fromPromise( - Promise.resolve(mockConnection), - (error) => ({ type: 'connection' as const, error: error as Event }), - ); + const opened = ResultAsync.fromPromise(Promise.resolve(mockConnection), (error) => ({ + type: 'connection' as const, + error: error as Event, + })); // eslint-disable-next-line neverthrow/must-use-result const closed = ResultAsync.fromPromise( new Promise(() => {}), - (error) => error as WebSocketCloseError, + (error) => error as WebSocketError, ); return { @@ -340,8 +332,8 @@ describe('SignalClient.connect', () => { it('should reject with NotAllowed error for 4xx HTTP status', async () => { // eslint-disable-next-line neverthrow/must-use-result const opened = ResultAsync.fromPromise( - Promise.reject(new Error('Connection failed')), - (error) => ({ type: 'connection' as const, error: error as Event }), + Promise.reject(new WebSocketError('Connection failed')), + (error) => error as WebSocketError, ); mockWebSocketStream({ opened, @@ -366,8 +358,8 @@ describe('SignalClient.connect', () => { it('should reject with ServerUnreachable when fetch fails', async () => { // eslint-disable-next-line neverthrow/must-use-result const opened = ResultAsync.fromPromise( - Promise.reject(new Error('Connection failed')), - (error) => ({ type: 'connection' as const, error: error as Event }), + Promise.reject(new WebSocketError('Connection failed')), + (error) => error as WebSocketError, ); mockWebSocketStream({ opened, @@ -384,17 +376,13 @@ describe('SignalClient.connect', () => { }); }); - it('should handle ConnectionError from WebSocket rejection', async () => { - const customError = new ConnectionError( - 'Custom error', - ConnectionErrorReason.InternalError, - 500, - ); + it('should handle WebsocketError from WebSocket rejection', async () => { + const customError = new WebSocketError('Custom error'); // eslint-disable-next-line neverthrow/must-use-result const opened = ResultAsync.fromPromise( Promise.reject(customError), - (error) => ({ type: 'connection' as const, error: error as Event }), + (error) => error as WebSocketError, ); mockWebSocketStream({ opened, @@ -497,13 +485,10 @@ describe('SignalClient.connect', () => { // eslint-disable-next-line neverthrow/must-use-result const opened = ResultAsync.fromPromise( new Promise(() => {}), - (error) => ({ type: 'connection' as const, error: error as Event }), + (error) => error as WebSocketError, ); // eslint-disable-next-line neverthrow/must-use-result - const closed = ResultAsync.fromPromise( - closedPromise, - (error) => error as WebSocketCloseError, - ); + const closed = ResultAsync.fromPromise(closedPromise, (error) => error as WebSocketError); return { url: 'wss://test.livekit.io', @@ -655,11 +640,13 @@ describe('SignalClient.validateFirstMessage', () => { const joinResponse = createJoinResponse(); const signalResponse = createSignalResponse('join', joinResponse); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); - expect(result.isValid).toBe(true); - expect(result.response).toEqual(joinResponse); + expect(result._unsafeUnwrap().response).toEqual(joinResponse); } }); @@ -679,11 +666,13 @@ describe('SignalClient.validateFirstMessage', () => { const reconnectResponse = new ReconnectResponse({ iceServers: [] }); const signalResponse = createSignalResponse('reconnect', reconnectResponse); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, true); - expect(result.isValid).toBe(true); - expect(result.response).toEqual(reconnectResponse); + expect(result._unsafeUnwrap().response).toEqual(reconnectResponse); } }); @@ -702,12 +691,14 @@ describe('SignalClient.validateFirstMessage', () => { const updateSignalResponse = createSignalResponse('update', { participants: [] }); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, updateSignalResponse, true); - expect(result.isValid).toBe(true); - expect(result.response).toBeUndefined(); - expect(result.shouldProcessFirstMessage).toBe(true); + expect(result._unsafeUnwrap().response).toBeUndefined(); + expect(result._unsafeUnwrap().shouldProcessFirstMessage).toBe(true); } }); @@ -718,12 +709,14 @@ describe('SignalClient.validateFirstMessage', () => { const leaveRequest = new LeaveRequest({ reason: 1 }); const signalResponse = createSignalResponse('leave', leaveRequest); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); - expect(result.isValid).toBe(false); - expect(result.error).toBeInstanceOf(ConnectionError); - expect(result.error?.reason).toBe(ConnectionErrorReason.LeaveRequest); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); + expect(result._unsafeUnwrapErr().reason).toBe(ConnectionErrorReason.LeaveRequest); } }); @@ -731,12 +724,14 @@ describe('SignalClient.validateFirstMessage', () => { const reconnectResponse = new ReconnectResponse({ iceServers: [] }); const signalResponse = createSignalResponse('reconnect', reconnectResponse); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); - expect(result.isValid).toBe(false); - expect(result.error).toBeInstanceOf(ConnectionError); - expect(result.error?.reason).toBe(ConnectionErrorReason.InternalError); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); + expect(result._unsafeUnwrapErr()).toBe(ConnectionErrorReason.InternalError); } }); }); diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 1cf3ce69ae..35beac2410 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -44,18 +44,21 @@ import { WrappedJoinRequest, protoInt64, } from '@livekit/protocol'; +import { Result, ResultAsync, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; import log, { LoggerNames, getLogger } from '../logger'; -import { ConnectionError, ConnectionErrorReason } from '../room/errors'; +import { AbortError, ConnectionError, ConnectionErrorReason, TimeoutError } from '../room/errors'; import CriticalTimers from '../room/timers'; import type { LoggerOptions } from '../room/types'; import { getClientInfo, isReactNative, sleep } from '../room/utils'; import { AsyncQueue } from '../utils/AsyncQueue'; -import { type WebSocketConnection, WebSocketStream } from './WebSocketStream'; +import { type WebSocketConnection, WebSocketError, WebSocketStream } from './WebSocketStream'; import { createRtcUrl, createValidateUrl, - getAbortReasonAsString, parseSignalResponse, + withAbort, + withMutex, + withTimeout, } from './utils'; // internal options @@ -252,117 +255,86 @@ export class SignalClient { this.state = SignalConnectionState.CONNECTING; this.options = opts; const res = await this.connect(url, token, opts, abortSignal); - return res as JoinResponse; + + console.warn('connection join vorbei'); + + if (res.isErr()) { + console.warn('connection error throw throw'); + + throw res.error; + } + + return res.value as JoinResponse; } - async reconnect( + reconnect( url: string, token: string, sid?: string, reason?: ReconnectReason, - ): Promise { + ): ResultAsync { if (!this.options) { - this.log.warn( - 'attempted to reconnect without signal options being set, ignoring', - this.logContext, + return errAsync( + new ConnectionError( + 'attempted to reconnect without signal options being set', + ConnectionErrorReason.InternalError, + ), ); - return; } this.state = SignalConnectionState.RECONNECTING; // clear ping interval and restart it once reconnected this.clearPingInterval(); - const res = (await this.connect(url, token, { + return this.connect(url, token, { ...this.options, reconnect: true, sid, reconnectReason: reason, - })) as ReconnectResponse | undefined; - return res; + }) as ResultAsync; } - private async connect( + private connect( url: string, token: string, opts: ConnectOpts, abortSignal?: AbortSignal, - ): Promise { - const unlock = await this.connectionLock.lock(); - - this.connectOptions = opts; - const clientInfo = getClientInfo(); - const params = opts.singlePeerConnection - ? createJoinRequestConnectionParams(token, clientInfo, opts) - : createConnectionParams(token, clientInfo, opts); - const rtcUrl = createRtcUrl(url, params); - const validateUrl = createValidateUrl(rtcUrl); - - return new Promise(async (resolve, reject) => { - try { - let alreadyAborted = false; - const abortHandler = async (eventOrError: Event | Error) => { - if (alreadyAborted) { - return; - } - alreadyAborted = true; - const target = eventOrError instanceof Event ? eventOrError.currentTarget : eventOrError; - const reason = getAbortReasonAsString(target, 'Abort handler called'); - // send leave if we have an active stream writer (connection is open) - if (this.streamWriter && !this.isDisconnected) { - this.sendLeave() - .then(() => this.close(reason)) - .catch((e) => { - this.log.error(e); - this.close(); - }); - } else { - this.close(); - } - cleanupAbortHandlers(); - reject(target instanceof AbortSignal ? target.reason : target); - }; - - abortSignal?.addEventListener('abort', abortHandler); - - const cleanupAbortHandlers = () => { - clearTimeout(wsTimeout); - abortSignal?.removeEventListener('abort', abortHandler); - }; - - const wsTimeout = setTimeout(() => { - abortHandler( - new ConnectionError( - 'room connection has timed out (signal)', - ConnectionErrorReason.ServerUnreachable, - ), - ); - }, opts.websocketTimeout); - - const handleSignalConnected = ( - connection: WebSocketConnection, - firstMessage?: SignalResponse, - ) => { - this.handleSignalConnected(connection, wsTimeout, firstMessage); - }; + ): ResultAsync< + JoinResponse | ReconnectResponse | undefined, + WebSocketError | TimeoutError | AbortError | ConnectionError | TypeError + > { + const self = this; + + return withMutex( + safeTry< + JoinResponse | ReconnectResponse | undefined, + WebSocketError | TimeoutError | AbortError | ConnectionError | TypeError + >(async function* () { + self.connectOptions = opts; + const clientInfo = getClientInfo(); + const params = opts.singlePeerConnection + ? createJoinRequestConnectionParams(token, clientInfo, opts) + : createConnectionParams(token, clientInfo, opts); + const rtcUrl = createRtcUrl(url, params); + const validateUrl = createValidateUrl(rtcUrl); const redactedUrl = new URL(rtcUrl); if (redactedUrl.searchParams.has('access_token')) { redactedUrl.searchParams.set('access_token', ''); } - this.log.debug(`connecting to ${redactedUrl}`, { + self.log.debug(`connecting to ${redactedUrl}`, { reconnect: opts.reconnect, reconnectReason: opts.reconnectReason, - ...this.logContext, + ...self.logContext, }); - if (this.ws) { - await this.close(false); + if (self.ws) { + await self.close(false); } - this.ws = new WebSocketStream(rtcUrl); + self.ws = new WebSocketStream(rtcUrl); - this.ws.closed.match( + self.ws.closed.match( (closeInfo) => { - if (this.isEstablishingConnection) { - reject( + if (self.isEstablishingConnection) { + return err( new ConnectionError( `Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`, ConnectionErrorReason.InternalError, @@ -370,22 +342,20 @@ export class SignalClient { ); } if (closeInfo.closeCode !== 1000) { - this.log.warn(`websocket closed`, { - ...this.logContext, + self.log.warn(`websocket closed`, { + ...self.logContext, reason: closeInfo.reason, code: closeInfo.closeCode, wasClean: closeInfo.closeCode === 1000, - state: this.state, + state: self.state, }); } }, (error) => { - const errorMessage = - error.type === 'unspecified' ? error.message : 'websocket error event'; - if (this.isEstablishingConnection) { - reject( + if (self.isEstablishingConnection) { + return err( new ConnectionError( - `Websocket error during a (re)connection attempt: ${errorMessage}`, + `Websocket error during a (re)connection attempt: ${error.message}`, ConnectionErrorReason.InternalError, ), ); @@ -393,85 +363,35 @@ export class SignalClient { }, ); - try { - const result = await this.ws.opened; - clearTimeout(wsTimeout); - - await result.match( - async (connection) => { - try { - const signalReader = connection.readable.getReader(); - this.streamWriter = connection.writable.getWriter(); - const firstMessage = await signalReader.read(); - signalReader.releaseLock(); - if (!firstMessage.value) { - reject( - new ConnectionError( - 'no message received as first message', - ConnectionErrorReason.InternalError, - ), - ); - return; - } - - const firstSignalResponse = parseSignalResponse(firstMessage.value); - - // Validate the first message - const validation = this.validateFirstMessage( - firstSignalResponse, - opts.reconnect ?? false, - ); - - if (!validation.isValid) { - reject(validation.error); - return; - } - - // Handle join response - set up ping configuration - if (firstSignalResponse.message?.case === 'join') { - this.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; - this.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; - - if (this.pingTimeoutDuration && this.pingTimeoutDuration > 0) { - this.log.debug('ping config', { - ...this.logContext, - timeout: this.pingTimeoutDuration, - interval: this.pingIntervalDuration, - }); - } - } - - // Handle successful connection - const firstMessageToProcess = validation.shouldProcessFirstMessage - ? firstSignalResponse - : undefined; - handleSignalConnected(connection, firstMessageToProcess); - resolve(validation.response); - } catch (e) { - reject(e); - } - }, - async (error) => { - if (this.state !== SignalConnectionState.CONNECTED) { - this.state = SignalConnectionState.DISCONNECTED; - const errorMessage = - error.type === 'abort' ? error.message : 'websocket connection error'; - const connectionError = await this.handleConnectionError(errorMessage, validateUrl); - reject(connectionError); - return; - } - // other errors, handle - this.handleWSError(error); - reject(error); - }, - ); - } finally { - cleanupAbortHandlers(); + const connection = await withAbort( + withTimeout(self.ws.opened, opts.websocketTimeout), + abortSignal, + ); + + if (connection.isErr()) { + console.warn('connection error is abgefahren'); + + const error = connection.error; + if (self.state !== SignalConnectionState.CONNECTED) { + self.state = SignalConnectionState.DISCONNECTED; + const connectionError = await withAbort( + withTimeout(self.handleConnectionError(error.message, validateUrl), 3_000), + abortSignal, + ); + self.close(undefined, error.type); + console.warn('connection returning den error'); + return err(connectionError); + } + // other errors, handle + self.handleWSError(error); + console.warn('connection returning den error'); + return err(error); } - } finally { - unlock(); - } - }); + + return self.processInitialSignalMessage(connection.value, opts.reconnect ?? false); + }), + this.connectionLock, + ); } async startReadingLoop( @@ -918,17 +838,65 @@ export class SignalClient { * @param firstMessage Optional first message to process * @internal */ - private handleSignalConnected( - connection: WebSocketConnection, - timeoutHandle: ReturnType, - firstMessage?: SignalResponse, - ) { + private handleSignalConnected(connection: WebSocketConnection, firstMessage?: SignalResponse) { this.state = SignalConnectionState.CONNECTED; - clearTimeout(timeoutHandle); this.startPingInterval(); this.startReadingLoop(connection.readable.getReader(), firstMessage); } + private processInitialSignalMessage( + connection: WebSocketConnection, + isReconnect: Reconnect, + ) { + const self = this; + return safeTry( + async function* () { + const signalReader = connection.readable.getReader(); + self.streamWriter = connection.writable.getWriter(); + console.log('type error start'); + + const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); + + console.log('type error end'); + + if (!firstMessage.value) { + return err( + new ConnectionError( + 'no message received as first message', + ConnectionErrorReason.InternalError, + ), + ); + } + + const firstSignalResponse = parseSignalResponse(firstMessage.value); + + // Validate the first message + const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); + + // Handle join response - set up ping configuration + if (firstSignalResponse.message?.case === 'join') { + self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; + self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; + + if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { + self.log.debug('ping config', { + ...self.logContext, + timeout: self.pingTimeoutDuration, + interval: self.pingIntervalDuration, + }); + } + } + + // Handle successful connection + const firstMessageToProcess = validation.shouldProcessFirstMessage + ? firstSignalResponse + : undefined; + self.handleSignalConnected(connection, firstMessageToProcess); + return okAsync(validation.response); + }, + ); + } + /** * Validates the first message received from the signal server * @param firstSignalResponse The first signal response received @@ -939,63 +907,58 @@ export class SignalClient { private validateFirstMessage( firstSignalResponse: SignalResponse, isReconnect: boolean, - ): { - isValid: boolean; - response?: JoinResponse | ReconnectResponse; - error?: ConnectionError; - shouldProcessFirstMessage?: boolean; - } { + ): Result< + ValidationType, + // TODO, this should probably not be a ConnectionError? + ConnectionError + > { if (firstSignalResponse.message?.case === 'join') { - return { - isValid: true, + return ok({ response: firstSignalResponse.message.value, - }; + shouldProcessFirstMessage: false, + }); } else if ( this.state === SignalConnectionState.RECONNECTING && firstSignalResponse.message?.case !== 'leave' ) { if (firstSignalResponse.message?.case === 'reconnect') { - return { - isValid: true, + return ok({ response: firstSignalResponse.message.value, - }; + shouldProcessFirstMessage: false, + }); } else { // in reconnecting, any message received means signal reconnected and we still need to process it this.log.debug( 'declaring signal reconnected without reconnect response received', this.logContext, ); - return { - isValid: true, + return ok({ response: undefined, shouldProcessFirstMessage: true, - }; + }); } } else if (this.isEstablishingConnection && firstSignalResponse.message?.case === 'leave') { - return { - isValid: false, - error: new ConnectionError( + return err( + new ConnectionError( 'Received leave request while trying to (re)connect', ConnectionErrorReason.LeaveRequest, undefined, firstSignalResponse.message.value.reason, ), - }; + ); } else if (!isReconnect) { // non-reconnect case, should receive join response first - return { - isValid: false, - error: new ConnectionError( + return err( + new ConnectionError( `did not receive join response, got ${firstSignalResponse.message?.case} instead`, ConnectionErrorReason.InternalError, ), - }; + ); } - return { - isValid: false, - error: new ConnectionError('Unexpected first message', ConnectionErrorReason.InternalError), - }; + return err( + new ConnectionError('Unexpected first message', ConnectionErrorReason.InternalError), + ); } /** @@ -1008,28 +971,32 @@ export class SignalClient { private async handleConnectionError( reason: unknown, validateUrl: string, - ): Promise { + ): Promise> { try { const resp = await fetch(validateUrl); if (resp.status.toFixed(0).startsWith('4')) { const msg = await resp.text(); - return new ConnectionError(msg, ConnectionErrorReason.NotAllowed, resp.status); + return err(new ConnectionError(msg, ConnectionErrorReason.NotAllowed, resp.status)); } else if (reason instanceof ConnectionError) { - return reason; + return err(reason); } else { - return new ConnectionError( - `Encountered unknown websocket error during connection: ${reason}`, - ConnectionErrorReason.InternalError, - resp.status, + return err( + new ConnectionError( + `Encountered unknown websocket error during connection: ${reason}`, + ConnectionErrorReason.InternalError, + resp.status, + ), ); } } catch (e) { - return e instanceof ConnectionError - ? e - : new ConnectionError( - e instanceof Error ? e.message : 'server was not reachable', - ConnectionErrorReason.ServerUnreachable, - ); + return err( + e instanceof ConnectionError + ? e + : new ConnectionError( + e instanceof Error ? e.message : 'server was not reachable', + ConnectionErrorReason.ServerUnreachable, + ), + ); } } } @@ -1146,3 +1113,13 @@ function createJoinRequestConnectionParams( return params; } + +export type ValidationType = + | { + response: JoinResponse | ReconnectResponse; + shouldProcessFirstMessage: false; + } + | { + response: undefined; + shouldProcessFirstMessage: true; + }; diff --git a/src/api/WebSocketStream.test.ts b/src/api/WebSocketStream.test.ts index f5a0949796..f5d11c15c5 100644 --- a/src/api/WebSocketStream.test.ts +++ b/src/api/WebSocketStream.test.ts @@ -232,7 +232,7 @@ describe('WebSocketStream', () => { const result = await wsStream.opened; expect(result.isErr()).toBe(true); if (result.isErr()) { - expect(result.error.type).toBe('connection'); + expect(result.error.type).toBe('websocket'); } }); }); @@ -283,8 +283,8 @@ describe('WebSocketStream', () => { const result = await wsStream.closed; expect(result.isErr()).toBe(true); if (result.isErr()) { - expect(result.error.type).toBe('unspecified'); - expect(result.error.type === 'unspecified' && result.error.message).toBe( + expect(result.error.type).toBe('websocket'); + expect(result.error.message).toBe( 'Encountered unspecified websocket error without a timely close event', ); } diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index 94d3324d0f..2598100409 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -1,5 +1,6 @@ // https://github.com/CarterLi/websocketstream-polyfill import { ResultAsync } from 'neverthrow'; +import { AbortError, LivekitError } from '../room/errors'; import { sleep } from '../room/utils'; export interface WebSocketConnection { @@ -14,13 +15,14 @@ export interface WebSocketCloseInfo { reason?: string; } -export type WebSocketOpenError = - | { type: 'abort'; message: string } - | { type: 'connection'; error: Event }; +// TODO: have more specific websocket errors and make this error type more useful in general +export class WebSocketError extends LivekitError { + readonly type = 'websocket'; -export type WebSocketCloseError = - | { type: 'unspecified'; message: string } - | { type: 'error'; error: Event }; + constructor(message: string) { + super(19, message); + } +} export interface WebSocketStreamOptions { protocols?: string[]; @@ -35,9 +37,9 @@ export interface WebSocketStreamOptions { export class WebSocketStream { readonly url: string; - readonly opened: ResultAsync, WebSocketOpenError>; + readonly opened: ResultAsync, WebSocketError>; - readonly closed: ResultAsync; + readonly closed: ResultAsync; readonly close!: (closeInfo?: WebSocketCloseInfo) => void; @@ -49,23 +51,28 @@ export class WebSocketStream ws.close(code, reason); // eslint-disable-next-line neverthrow/must-use-result - this.opened = ResultAsync.fromPromise, WebSocketOpenError>( - new Promise((resolve, reject) => { + this.opened = ResultAsync.fromPromise, WebSocketError>( + new Promise((resolve, r) => { + const reject = (err: WebSocketError) => r(err); const errorHandler = (e: Event) => { - reject(e); + console.error(e); + reject(new WebSocketError('Encountered websocket error while establishing connection')); ws.removeEventListener('open', openHandler); }; @@ -93,15 +100,18 @@ export class WebSocketStream ({ type: 'connection', error: error as Event }), + (error) => error as WebSocketError, ); // eslint-disable-next-line neverthrow/must-use-result - this.closed = ResultAsync.fromPromise( - new Promise((resolve, reject) => { + this.closed = ResultAsync.fromPromise( + new Promise((resolve, r) => { + const reject = (err: WebSocketError) => r(err); const errorHandler = async () => { const closePromise = new Promise((res) => { if (ws.readyState === WebSocket.CLOSED) return; @@ -117,10 +127,11 @@ export class WebSocketStream error as WebSocketCloseError, + (error) => error as WebSocketError, ); if (options.signal) { - options.signal.onabort = () => ws.close(); + options.signal.onabort = () => ws.close(undefined, 'AbortSignal triggered'); } this.close = closeWithInfo; diff --git a/src/api/utils.ts b/src/api/utils.ts index 3fb538a9a4..334f2231cd 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,4 +1,7 @@ +import type { Mutex } from '@livekit/mutex'; import { SignalResponse } from '@livekit/protocol'; +import { Result, ResultAsync, errAsync } from 'neverthrow'; +import { AbortError, TimeoutError } from '../room/errors'; import { toHttpUrl, toWebsocketUrl } from '../room/utils'; export function createRtcUrl(url: string, searchParams: URLSearchParams) { @@ -49,3 +52,83 @@ export function getAbortReasonAsString( return 'toString' in reason ? reason.toString() : defaultMessage; } } + +export function withTimeout( + ra: ResultAsync | Promise>, + ms: number, +): ResultAsync { + const toSettledPromise: PromiseLike = ra.then((res) => + // `res` is a Result; resolve with T or reject with E + res.match( + (v) => Promise.resolve(v), + (err) => Promise.reject(err), + ), + ); + + const timeout = new Promise((_, reject) => + setTimeout(() => { + console.warn('timeout triggered'); + + reject(new TimeoutError()); + }, ms), + ); + + return ResultAsync.fromPromise( + // race returns a Promise that resolves with T or rejects with E/onTimeout() + Promise.race([toSettledPromise, timeout]), + // map any thrown/rejected value into the error type E + (e) => e as E | TimeoutError, + ); +} + +export function withAbort( + ra: ResultAsync, + signal: AbortSignal | undefined, +): ResultAsync { + if (signal?.aborted) { + return errAsync(new AbortError()); + } + + const abortPromise = new Promise((_, reject) => { + const onAbortHandler = () => { + signal?.removeEventListener('abort', onAbortHandler); + reject(new AbortError()); + }; + signal?.addEventListener('abort', onAbortHandler); + }); + + const toSettledPromise: PromiseLike = ra.then((res) => + res.match( + (v) => Promise.resolve(v), + (err) => Promise.reject(err), + ), + ); + + return ResultAsync.fromPromise( + Promise.race([toSettledPromise, abortPromise]), + (e) => e as E | AbortError, + ); +} + +export function withMutex( + fn: ResultAsync | Result, + mutex: Mutex, +): ResultAsync { + return ResultAsync.fromPromise( + (async () => { + const unlock = await mutex.lock(); + try { + const res = await fn; + return res.match( + (v) => v, + (err) => { + throw err as Error; + }, + ); + } finally { + unlock(); + } + })(), + (e) => e as E, + ); +} diff --git a/src/room/errors.ts b/src/room/errors.ts index 5c4c842aab..4f57961909 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -111,6 +111,24 @@ export class SignalRequestError extends LivekitError { } } +export class TimeoutError extends LivekitError { + readonly type = 'timeout'; + + constructor(message: string = 'TimeoutError') { + super(17, message); + this.name = 'TimeoutError'; + } +} + +export class AbortError extends LivekitError { + readonly type = 'abort'; + + constructor(message: string = 'AbortError') { + super(18, message); + this.name = 'AbortError'; + } +} + // NOTE: matches with https://github.com/livekit/client-sdk-swift/blob/f37bbd260d61e165084962db822c79f995f1a113/Sources/LiveKit/DataStream/StreamError.swift#L17 export enum DataStreamErrorReason { // Unable to open a stream with the same ID more than once. From d2b40e189c51d2008e427a0b2915ff9b6bcc5af4 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 20:14:44 +0100 Subject: [PATCH 05/58] more progress --- src/api/SignalClient.ts | 164 +++++++++++++++++--------------------- src/room/RTCEngine.ts | 170 +++++++++++++++++++++------------------- src/room/Room.ts | 9 ++- src/room/errors.ts | 10 +++ 4 files changed, 179 insertions(+), 174 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 35beac2410..d9cad340e4 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -44,7 +44,7 @@ import { WrappedJoinRequest, protoInt64, } from '@livekit/protocol'; -import { Result, ResultAsync, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; +import { Result, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; import log, { LoggerNames, getLogger } from '../logger'; import { AbortError, ConnectionError, ConnectionErrorReason, TimeoutError } from '../room/errors'; import CriticalTimers from '../room/timers'; @@ -244,35 +244,15 @@ export class SignalClient { return this.loggerContextCb?.() ?? {}; } - async join( - url: string, - token: string, - opts: SignalOptions, - abortSignal?: AbortSignal, - ): Promise { + async join(url: string, token: string, opts: SignalOptions, abortSignal?: AbortSignal) { // during a full reconnect, we'd want to start the sequence even if currently // connected this.state = SignalConnectionState.CONNECTING; this.options = opts; - const res = await this.connect(url, token, opts, abortSignal); - - console.warn('connection join vorbei'); - - if (res.isErr()) { - console.warn('connection error throw throw'); - - throw res.error; - } - - return res.value as JoinResponse; + return this.connect(url, token, opts, abortSignal); } - reconnect( - url: string, - token: string, - sid?: string, - reason?: ReconnectReason, - ): ResultAsync { + reconnect(url: string, token: string, sid?: string, reason?: ReconnectReason) { if (!this.options) { return errAsync( new ConnectionError( @@ -290,24 +270,15 @@ export class SignalClient { reconnect: true, sid, reconnectReason: reason, - }) as ResultAsync; + }); } - private connect( - url: string, - token: string, - opts: ConnectOpts, - abortSignal?: AbortSignal, - ): ResultAsync< - JoinResponse | ReconnectResponse | undefined, - WebSocketError | TimeoutError | AbortError | ConnectionError | TypeError - > { + private connect(url: string, token: string, opts: ConnectOpts, abortSignal?: AbortSignal) { const self = this; - return withMutex( safeTry< JoinResponse | ReconnectResponse | undefined, - WebSocketError | TimeoutError | AbortError | ConnectionError | TypeError + WebSocketError | TimeoutError | AbortError | ConnectionError >(async function* () { self.connectOptions = opts; const clientInfo = getClientInfo(); @@ -380,15 +351,21 @@ export class SignalClient { ); self.close(undefined, error.type); console.warn('connection returning den error'); - return err(connectionError); + return connectionError; } // other errors, handle self.handleWSError(error); console.warn('connection returning den error'); - return err(error); + return errAsync(error); } - return self.processInitialSignalMessage(connection.value, opts.reconnect ?? false); + return withAbort( + withTimeout( + self.processInitialSignalMessage(connection.value, opts.reconnect ?? false), + 5_000, + ), + abortSignal, + ); }), this.connectionLock, ); @@ -844,57 +821,56 @@ export class SignalClient { this.startReadingLoop(connection.readable.getReader(), firstMessage); } - private processInitialSignalMessage( - connection: WebSocketConnection, - isReconnect: Reconnect, - ) { + private processInitialSignalMessage< + Reconnect extends boolean, + InitialReturn extends boolean extends false ? JoinResponse : ReconnectResponse | undefined, + >(connection: WebSocketConnection, isReconnect: Reconnect) { const self = this; - return safeTry( - async function* () { - const signalReader = connection.readable.getReader(); - self.streamWriter = connection.writable.getWriter(); - console.log('type error start'); + // TODO: This should be more granular here than ConnectionError + return safeTry(async function* () { + const signalReader = connection.readable.getReader(); + self.streamWriter = connection.writable.getWriter(); + console.log('type error start'); - const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); + const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); - console.log('type error end'); + console.log('type error end'); - if (!firstMessage.value) { - return err( - new ConnectionError( - 'no message received as first message', - ConnectionErrorReason.InternalError, - ), - ); - } + if (!firstMessage.value) { + return err( + new ConnectionError( + 'no message received as first message', + ConnectionErrorReason.InternalError, + ), + ); + } - const firstSignalResponse = parseSignalResponse(firstMessage.value); + const firstSignalResponse = parseSignalResponse(firstMessage.value); - // Validate the first message - const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); + // Validate the first message + const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); - // Handle join response - set up ping configuration - if (firstSignalResponse.message?.case === 'join') { - self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; - self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; + // Handle join response - set up ping configuration + if (firstSignalResponse.message?.case === 'join') { + self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; + self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; - if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { - self.log.debug('ping config', { - ...self.logContext, - timeout: self.pingTimeoutDuration, - interval: self.pingIntervalDuration, - }); - } + if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { + self.log.debug('ping config', { + ...self.logContext, + timeout: self.pingTimeoutDuration, + interval: self.pingIntervalDuration, + }); } + } - // Handle successful connection - const firstMessageToProcess = validation.shouldProcessFirstMessage - ? firstSignalResponse - : undefined; - self.handleSignalConnected(connection, firstMessageToProcess); - return okAsync(validation.response); - }, - ); + // Handle successful connection + const firstMessageToProcess = validation.shouldProcessFirstMessage + ? firstSignalResponse + : undefined; + self.handleSignalConnected(connection, firstMessageToProcess); + return okAsync(validation.response); + }); } /** @@ -904,20 +880,21 @@ export class SignalClient { * @returns Validation result with response or error * @internal */ - private validateFirstMessage( + private validateFirstMessage( firstSignalResponse: SignalResponse, - isReconnect: boolean, + isReconnect: Reconnect, ): Result< - ValidationType, + ValidationType, // TODO, this should probably not be a ConnectionError? ConnectionError > { - if (firstSignalResponse.message?.case === 'join') { + if (isReconnect === false && firstSignalResponse.message?.case === 'join') { return ok({ response: firstSignalResponse.message.value, shouldProcessFirstMessage: false, }); } else if ( + isReconnect === true && this.state === SignalConnectionState.RECONNECTING && firstSignalResponse.message?.case !== 'leave' ) { @@ -1114,12 +1091,17 @@ function createJoinRequestConnectionParams( return params; } -export type ValidationType = - | { - response: JoinResponse | ReconnectResponse; +export type ValidationType = Reconnect extends false + ? { + response: JoinResponse; shouldProcessFirstMessage: false; } - | { - response: undefined; - shouldProcessFirstMessage: true; - }; + : + | { + response: ReconnectResponse; + shouldProcessFirstMessage: false; + } + | { + response: undefined; + shouldProcessFirstMessage: true; + }; diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index a12b574aee..a47b686824 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -39,6 +39,7 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; +import { type Result, err, ok } from 'neverthrow'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; @@ -62,6 +63,7 @@ import { ConnectionError, ConnectionErrorReason, NegotiationError, + SimulatedError, TrackInvalidError, UnexpectedConnectionState, } from './errors'; @@ -250,38 +252,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit token: string, opts: SignalOptions, abortSignal?: AbortSignal, - ): Promise { + // TODO: be more explicit about error types + ): Promise> { this.url = url; this.token = token; this.signalOpts = opts; this.maxJoinAttempts = opts.maxRetries; - try { - this.joinAttempts += 1; - - this.setupSignalClientCallbacks(); - const joinResponse = await this.client.join(url, token, opts, abortSignal); - this._isClosed = false; - this.latestJoinResponse = joinResponse; - - this.subscriberPrimary = joinResponse.subscriberPrimary; - if (!this.pcManager) { - await this.configure(joinResponse); - } + this.joinAttempts += 1; - // create offer - if (!this.subscriberPrimary || joinResponse.fastPublish) { - this.negotiate().catch((err) => { - log.error(err, this.logContext); - }); - } + this.setupSignalClientCallbacks(); + const joinResult = await this.client.join(url, token, opts, abortSignal); - this.registerOnLineListener(); - this.clientConfiguration = joinResponse.clientConfiguration; - this.emit(EngineEvent.SignalConnected, joinResponse); - return joinResponse; - } catch (e) { - if (e instanceof ConnectionError) { - if (e.reason === ConnectionErrorReason.ServerUnreachable) { + if (joinResult.isErr()) { + const error = joinResult.error; + if (error instanceof ConnectionError) { + if (error.reason === ConnectionErrorReason.ServerUnreachable) { this.log.warn( `Couldn't connect to server, attempt ${this.joinAttempts} of ${this.maxJoinAttempts}`, this.logContext, @@ -291,8 +276,30 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } } - throw e; + return err(error); } + + const joinResponse = joinResult.value; + + this._isClosed = false; + this.latestJoinResponse = joinResponse; + + this.subscriberPrimary = joinResponse.subscriberPrimary; + if (!this.pcManager) { + await this.configure(joinResponse); + } + + // create offer + if (!this.subscriberPrimary || joinResponse.fastPublish) { + this.negotiate().catch((error) => { + log.error(error, this.logContext); + }); + } + + this.registerOnLineListener(); + this.clientConfiguration = joinResponse.clientConfiguration; + this.emit(EngineEvent.SignalConnected, joinResponse); + return ok(joinResponse); } async close() { @@ -979,23 +986,26 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.fullReconnectOnNext = true; } - try { - this.attemptingReconnect = true; - if (this.fullReconnectOnNext) { - await this.restartConnection(); - } else { - await this.resumeConnection(reason); - } - this.clearPendingReconnect(); - this.fullReconnectOnNext = false; - } catch (e) { + let result: Result; + this.attemptingReconnect = true; + if (this.fullReconnectOnNext) { + result = await this.restartConnection(); + } else { + result = await this.resumeConnection(reason); + } + this.clearPendingReconnect(); + this.fullReconnectOnNext = false; + if (result.isErr()) { + const error = result.error; this.reconnectAttempts += 1; let recoverable = true; - if (e instanceof UnexpectedConnectionState) { - this.log.debug('received unrecoverable error', { ...this.logContext, error: e }); + // TODO this needs proper handling to define which errors are actually unexpected and non recoverable + // Currently all connection related errors are ConnectionErrors + if (error instanceof UnexpectedConnectionState) { + this.log.debug('received unrecoverable error', { ...this.logContext, error }); // unrecoverable recoverable = false; - } else if (!(e instanceof SignalReconnectError)) { + } else if (!(error instanceof SignalReconnectError)) { // cannot resume this.fullReconnectOnNext = true; } @@ -1012,9 +1022,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.emit(EngineEvent.Disconnected); await this.close(); } - } finally { - this.attemptingReconnect = false; } + this.attemptingReconnect = false; } private getNextRetryDelay(context: ReconnectContext) { @@ -1028,11 +1037,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return null; } - private async restartConnection(regionUrl?: string) { + private async restartConnection( + regionUrl?: string, + ): Promise> { try { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection - throw new UnexpectedConnectionState('could not reconnect, url or token not saved'); + return err(new UnexpectedConnectionState('could not reconnect, url or token not saved')); } this.log.info(`reconnecting, attempt: ${this.reconnectAttempts}`, this.logContext); @@ -1044,47 +1055,46 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit await this.cleanupPeerConnections(); await this.cleanupClient(); - let joinResponse: JoinResponse; - try { - if (!this.signalOpts) { - this.log.warn( - 'attempted connection restart, without signal options present', - this.logContext, - ); - throw new SignalReconnectError(); - } - // in case a regionUrl is passed, the region URL takes precedence - joinResponse = await this.join(regionUrl ?? this.url, this.token, this.signalOpts); - } catch (e) { - if (e instanceof ConnectionError && e.reason === ConnectionErrorReason.NotAllowed) { - throw new UnexpectedConnectionState('could not reconnect, token might be expired'); - } + if (!this.signalOpts) { + this.log.warn( + 'attempted connection restart, without signal options present', + this.logContext, + ); throw new SignalReconnectError(); } + // in case a regionUrl is passed, the region URL takes precedence + const joinResult = await this.join(regionUrl ?? this.url, this.token, this.signalOpts); + if (joinResult.isErr()) { + const error = joinResult.error; + if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.NotAllowed) { + return err(new UnexpectedConnectionState('could not reconnect, token might be expired')); + } + return err(new SignalReconnectError()); + } if (this.shouldFailNext) { this.shouldFailNext = false; - throw new Error('simulated failure'); + return err(new SimulatedError()); } this.client.setReconnected(); - this.emit(EngineEvent.SignalRestarted, joinResponse); + this.emit(EngineEvent.SignalRestarted, joinResult.value); await this.waitForPCReconnected(); // re-check signal connection state before setting engine as resumed if (this.client.currentState !== SignalConnectionState.CONNECTED) { - throw new SignalReconnectError('Signal connection got severed during reconnect'); + return err(new SignalReconnectError('Signal connection got severed during reconnect')); } this.regionUrlProvider?.resetAttempts(); // reconnect success this.emit(EngineEvent.Restarted); + return ok(); } catch (error) { const nextRegionUrl = await this.regionUrlProvider?.getNextBestRegionUrl(); if (nextRegionUrl) { - await this.restartConnection(nextRegionUrl); - return; + return this.restartConnection(nextRegionUrl); } else { // no more regions to try (or we're not on cloud) this.regionUrlProvider?.resetAttempts(); @@ -1093,7 +1103,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } - private async resumeConnection(reason?: ReconnectReason): Promise { + private async resumeConnection(reason?: ReconnectReason) { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection throw new UnexpectedConnectionState('could not reconnect, url or token not saved'); @@ -1106,23 +1116,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.log.info(`resuming signal connection, attempt ${this.reconnectAttempts}`, this.logContext); this.emit(EngineEvent.Resuming); let res: ReconnectResponse | undefined; - try { - this.setupSignalClientCallbacks(); - res = await this.client.reconnect(this.url, this.token, this.participantSid, reason); - } catch (error) { - let message = ''; - if (error instanceof Error) { - message = error.message; - this.log.error(error.message, { ...this.logContext, error }); - } - if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.NotAllowed) { - throw new UnexpectedConnectionState('could not reconnect, token might be expired'); - } - if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.LeaveRequest) { - throw error; - } - throw new SignalReconnectError(message); + this.setupSignalClientCallbacks(); + const reconnectResult = await this.client.reconnect( + this.url, + this.token, + this.participantSid, + reason, + ); + if (reconnectResult.isErr()) { + return err(reconnectResult.error); } + this.emit(EngineEvent.SignalResumed); if (res) { @@ -1137,7 +1141,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (this.shouldFailNext) { this.shouldFailNext = false; - throw new Error('simulated failure'); + return err(new SimulatedError()); } await this.pcManager.triggerIceRestart(); @@ -1163,6 +1167,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // resume success this.emit(EngineEvent.Resumed); + + return ok(); } async waitForPCInitialConnection(timeout?: number, abortController?: AbortController) { diff --git a/src/room/Room.ts b/src/room/Room.ts index 6c00244918..69eca66a27 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -773,7 +773,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) roomOptions: InternalRoomOptions, abortController: AbortController, ): Promise => { - const joinResponse = await engine.join( + const joinResult = await engine.join( url, token, { @@ -788,6 +788,13 @@ class Room extends (EventEmitter as new () => TypedEmitter) abortController.signal, ); + // TODO continue propagating Result, we don't need to throw here + if (joinResult.isErr()) { + throw joinResult.error; + } + + const joinResponse = joinResult.value; + let serverInfo: Partial | undefined = joinResponse.serverInfo; if (!serverInfo) { serverInfo = { version: joinResponse.serverVersion, region: joinResponse.serverRegion }; diff --git a/src/room/errors.ts b/src/room/errors.ts index 4f57961909..9b8a33a466 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -1,5 +1,7 @@ import { DisconnectReason, RequestResponse_Reason } from '@livekit/protocol'; +// TODO properly define necessary Errors (make them backwards compatible) + export class LivekitError extends Error { code: number; @@ -10,6 +12,14 @@ export class LivekitError extends Error { } } +export class SimulatedError extends LivekitError { + readonly type = 'simulated'; + + constructor(message = 'Simulated failure') { + super(0, message); + } +} + export enum ConnectionErrorReason { NotAllowed, ServerUnreachable, From 4300ddeeccebdd226b1c621879a95909c863904c Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 20:42:57 +0100 Subject: [PATCH 06/58] better typing --- src/api/SignalClient.ts | 65 +++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index d9cad340e4..4b2e1b8bd4 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -44,7 +44,7 @@ import { WrappedJoinRequest, protoInt64, } from '@livekit/protocol'; -import { Result, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; +import { Result, ResultAsync, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; import log, { LoggerNames, getLogger } from '../logger'; import { AbortError, ConnectionError, ConnectionErrorReason, TimeoutError } from '../room/errors'; import CriticalTimers from '../room/timers'; @@ -63,8 +63,6 @@ import { // internal options interface ConnectOpts extends SignalOptions { - /** internal */ - reconnect?: boolean; /** internal */ reconnectReason?: number; /** internal */ @@ -249,7 +247,7 @@ export class SignalClient { // connected this.state = SignalConnectionState.CONNECTING; this.options = opts; - return this.connect(url, token, opts, abortSignal); + return this.connect(url, token, false, opts, abortSignal); } reconnect(url: string, token: string, sid?: string, reason?: ReconnectReason) { @@ -265,26 +263,25 @@ export class SignalClient { // clear ping interval and restart it once reconnected this.clearPingInterval(); - return this.connect(url, token, { + return this.connect(url, token, true, { ...this.options, - reconnect: true, sid, reconnectReason: reason, }); } - private connect(url: string, token: string, opts: ConnectOpts, abortSignal?: AbortSignal) { + private connect< + T extends boolean, + U extends T extends false ? JoinResponse : ReconnectResponse | undefined, + >(url: string, token: string, isReconnect: T, opts: ConnectOpts, abortSignal?: AbortSignal) { const self = this; return withMutex( - safeTry< - JoinResponse | ReconnectResponse | undefined, - WebSocketError | TimeoutError | AbortError | ConnectionError - >(async function* () { + safeTry(async function* () { self.connectOptions = opts; const clientInfo = getClientInfo(); const params = opts.singlePeerConnection - ? createJoinRequestConnectionParams(token, clientInfo, opts) - : createConnectionParams(token, clientInfo, opts); + ? createJoinRequestConnectionParams(token, clientInfo, opts, isReconnect) + : createConnectionParams(token, clientInfo, opts, isReconnect); const rtcUrl = createRtcUrl(url, params); const validateUrl = createValidateUrl(rtcUrl); @@ -293,7 +290,7 @@ export class SignalClient { redactedUrl.searchParams.set('access_token', ''); } self.log.debug(`connecting to ${redactedUrl}`, { - reconnect: opts.reconnect, + reconnect: isReconnect, reconnectReason: opts.reconnectReason, ...self.logContext, }); @@ -340,8 +337,6 @@ export class SignalClient { ); if (connection.isErr()) { - console.warn('connection error is abgefahren'); - const error = connection.error; if (self.state !== SignalConnectionState.CONNECTED) { self.state = SignalConnectionState.DISCONNECTED; @@ -350,20 +345,15 @@ export class SignalClient { abortSignal, ); self.close(undefined, error.type); - console.warn('connection returning den error'); return connectionError; } // other errors, handle self.handleWSError(error); - console.warn('connection returning den error'); return errAsync(error); } return withAbort( - withTimeout( - self.processInitialSignalMessage(connection.value, opts.reconnect ?? false), - 5_000, - ), + withTimeout(self.processInitialSignalMessage(connection.value, isReconnect), 5_000), abortSignal, ); }), @@ -822,20 +812,16 @@ export class SignalClient { } private processInitialSignalMessage< - Reconnect extends boolean, - InitialReturn extends boolean extends false ? JoinResponse : ReconnectResponse | undefined, - >(connection: WebSocketConnection, isReconnect: Reconnect) { + T extends boolean, + U extends T extends false ? JoinResponse : ReconnectResponse | undefined, + >(connection: WebSocketConnection, isReconnect: T): ResultAsync { const self = this; // TODO: This should be more granular here than ConnectionError - return safeTry(async function* () { + return safeTry(async function* () { const signalReader = connection.readable.getReader(); self.streamWriter = connection.writable.getWriter(); - console.log('type error start'); - const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); - console.log('type error end'); - if (!firstMessage.value) { return err( new ConnectionError( @@ -846,15 +832,12 @@ export class SignalClient { } const firstSignalResponse = parseSignalResponse(firstMessage.value); - // Validate the first message const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); - // Handle join response - set up ping configuration if (firstSignalResponse.message?.case === 'join') { self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; - if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { self.log.debug('ping config', { ...self.logContext, @@ -869,7 +852,7 @@ export class SignalClient { ? firstSignalResponse : undefined; self.handleSignalConnected(connection, firstMessageToProcess); - return okAsync(validation.response); + return okAsync(validation.response as U); }); } @@ -880,11 +863,13 @@ export class SignalClient { * @returns Validation result with response or error * @internal */ - private validateFirstMessage( + private validateFirstMessage( firstSignalResponse: SignalResponse, - isReconnect: Reconnect, + isReconnect: boolean, ): Result< - ValidationType, + | { response: JoinResponse; shouldProcessFirstMessage: false } + | { response: ReconnectResponse; shouldProcessFirstMessage: false } + | { response: undefined; shouldProcessFirstMessage: true }, // TODO, this should probably not be a ConnectionError? ConnectionError > { @@ -1012,12 +997,13 @@ function createConnectionParams( token: string, info: ClientInfo, opts: ConnectOpts, + isReconnect: boolean, ): URLSearchParams { const params = new URLSearchParams(); params.set('access_token', token); // opts - if (opts.reconnect) { + if (isReconnect) { params.set('reconnect', '1'); if (opts.sid) { params.set('sid', opts.sid); @@ -1067,6 +1053,7 @@ function createJoinRequestConnectionParams( token: string, info: ClientInfo, opts: ConnectOpts, + isReconnect: boolean, ): URLSearchParams { const params = new URLSearchParams(); params.set('access_token', token); @@ -1077,7 +1064,7 @@ function createJoinRequestConnectionParams( autoSubscribe: !!opts.autoSubscribe, adaptiveStream: !!opts.adaptiveStream, }), - reconnect: !!opts.reconnect, + reconnect: isReconnect, participantSid: opts.sid ? opts.sid : undefined, }); if (opts.reconnectReason) { From b16ea6921475c3c5458038207835776621ef6d29 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 20:47:11 +0100 Subject: [PATCH 07/58] fix scope --- src/room/RTCEngine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index a47b686824..d77052ee07 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1381,8 +1381,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } if (needNegotiation) { // start negotiation - this.negotiate().catch((err) => { - log.error(err, this.logContext); + this.negotiate().catch((error) => { + log.error(error, this.logContext); }); } From 2411f4ee317819fce04d5260fc5afe95909bc292 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 21:09:40 +0100 Subject: [PATCH 08/58] fix all tests --- src/api/SignalClient.test.ts | 193 ++++++++++++++++---------------- src/api/SignalClient.ts | 6 +- src/api/WebSocketStream.test.ts | 2 +- 3 files changed, 100 insertions(+), 101 deletions(-) diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index 97f928ac3b..c4f593d554 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -121,7 +121,7 @@ describe('SignalClient.connect', () => { const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); - expect(result).toEqual(joinResponse); + expect(result._unsafeUnwrap()).toEqual(joinResponse); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); }); @@ -150,7 +150,7 @@ describe('SignalClient.connect', () => { const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123'); - expect(result).toEqual(reconnectResponse); + expect(result._unsafeUnwrap()).toEqual(reconnectResponse); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); @@ -175,7 +175,7 @@ describe('SignalClient.connect', () => { const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123'); // This is an edge case: reconnect resolves with undefined when non-reconnect message is received - expect(result).toBeUndefined(); + expect(result._unsafeUnwrap()).toBeUndefined(); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }, 1000); }); @@ -189,9 +189,10 @@ describe('SignalClient.connect', () => { websocketTimeout: 100, }; - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', shortTimeoutOptions), - ).rejects.toThrow(ConnectionError); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', shortTimeoutOptions); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); }); }); @@ -223,14 +224,15 @@ describe('SignalClient.connect', () => { } as any; }); - await expect( - signalClient.join( - 'wss://test.livekit.io', - 'test-token', - defaultOptions, - abortController.signal, - ), - ).rejects.toThrow('User aborted connection'); + const result = await signalClient.join( + 'wss://test.livekit.io', + 'test-token', + defaultOptions, + abortController.signal, + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().message).toBe('AbortError'); }); it('should send leave request before closing when AbortSignal is triggered during connection', async () => { @@ -306,8 +308,10 @@ describe('SignalClient.connect', () => { // Now abort the connection (after WS opens, before join response) abortController.abort(new Error('User aborted connection')); - // joinPromise should reject - await expect(joinPromise).rejects.toThrow('User aborted connection'); + // joinPromise should return Err result + const result = await joinPromise; + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().message).toBe('AbortError'); // Verify that a leave request was sent before closing const leaveRequestSent = writtenMessages.some((data) => { @@ -346,13 +350,14 @@ describe('SignalClient.connect', () => { text: async () => 'Forbidden', }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'Forbidden', - reason: ConnectionErrorReason.NotAllowed, - status: 403, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('Forbidden'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.NotAllowed); + expect((error as ConnectionError).status).toBe(403); }); it('should reject with ServerUnreachable when fetch fails', async () => { @@ -369,11 +374,12 @@ describe('SignalClient.connect', () => { // Mock fetch to throw (network error) (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - reason: ConnectionErrorReason.ServerUnreachable, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.ServerUnreachable); }); it('should handle WebsocketError from WebSocket rejection', async () => { @@ -395,11 +401,12 @@ describe('SignalClient.connect', () => { text: async () => 'Internal Server Error', }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - reason: ConnectionErrorReason.InternalError, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.InternalError); }); }); @@ -415,12 +422,13 @@ describe('SignalClient.connect', () => { mockWebSocketStream({ connection: mockConnection }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'no message received as first message', - reason: ConnectionErrorReason.InternalError, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('no message received as first message'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.InternalError); }); }); @@ -435,16 +443,14 @@ describe('SignalClient.connect', () => { mockWebSocketStream({ connection: mockConnection }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject( - new ConnectionError( - 'Received leave request while trying to (re)connect', - ConnectionErrorReason.LeaveRequest, - undefined, - 1, - ), - ); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('Received leave request while trying to (re)connect'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.LeaveRequest); + expect((error as ConnectionError).context).toBe(1); }); }); @@ -460,51 +466,31 @@ describe('SignalClient.connect', () => { mockWebSocketStream({ connection: mockConnection }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'did not receive join response, got reconnect instead', - reason: ConnectionErrorReason.InternalError, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('did not receive join response, got reconnect instead'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.InternalError); }); }); describe('Failure Case - WebSocket Closed During Connection', () => { it('should reject when WebSocket closes during connection attempt', async () => { - let closedResolve: (value: WebSocketCloseInfo) => void; - const closedPromise = new Promise((resolve) => { - closedResolve = resolve; - }); - - vi.mocked(WebSocketStream).mockImplementation(() => { - // Simulate close during connection - queueMicrotask(() => { - closedResolve({ closeCode: 1006, reason: 'Connection lost' }); - }); + mockWebSocketStream({ readyState: 3 }); // CLOSED - // eslint-disable-next-line neverthrow/must-use-result - const opened = ResultAsync.fromPromise( - new Promise(() => {}), - (error) => error as WebSocketError, - ); - // eslint-disable-next-line neverthrow/must-use-result - const closed = ResultAsync.fromPromise(closedPromise, (error) => error as WebSocketError); + const shortTimeoutOptions = { + ...defaultOptions, + websocketTimeout: 100, + }; - return { - url: 'wss://test.livekit.io', - opened, - closed, - close: vi.fn(), - readyState: 2, // CLOSING - } as any; - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', shortTimeoutOptions); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'Websocket got closed during a (re)connection attempt: Connection lost', - reason: ConnectionErrorReason.InternalError, - }); + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + // When connection fails, it will timeout since opened never resolves + expect(error).toBeInstanceOf(ConnectionError); }); }); @@ -731,7 +717,7 @@ describe('SignalClient.validateFirstMessage', () => { if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); - expect(result._unsafeUnwrapErr()).toBe(ConnectionErrorReason.InternalError); + expect(result._unsafeUnwrapErr().reason).toBe(ConnectionErrorReason.InternalError); } }); }); @@ -755,10 +741,12 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBeInstanceOf(ConnectionError); - expect(result.reason).toBe(ConnectionErrorReason.NotAllowed); - expect(result.status).toBe(403); - expect(result.message).toBe('Forbidden'); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBeInstanceOf(ConnectionError); + expect(err.reason).toBe(ConnectionErrorReason.NotAllowed); + expect(err.status).toBe(403); + expect(err.message).toBe('Forbidden'); } }); @@ -782,8 +770,10 @@ describe('SignalClient.handleConnectionError', () => { 'wss://test.livekit.io/validate', ); - expect(result).toBe(connectionError); - expect(result.reason).toBe(ConnectionErrorReason.InternalError); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBe(connectionError); + expect(err.reason).toBe(ConnectionErrorReason.InternalError); } }); @@ -798,9 +788,11 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBeInstanceOf(ConnectionError); - expect(result.reason).toBe(ConnectionErrorReason.InternalError); - expect(result.status).toBe(500); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBeInstanceOf(ConnectionError); + expect(err.reason).toBe(ConnectionErrorReason.InternalError); + expect(err.status).toBe(500); } }); @@ -812,8 +804,10 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBeInstanceOf(ConnectionError); - expect(result.reason).toBe(ConnectionErrorReason.ServerUnreachable); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBeInstanceOf(ConnectionError); + expect(err.reason).toBe(ConnectionErrorReason.ServerUnreachable); } }); @@ -826,7 +820,8 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBe(fetchError); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBe(fetchError); } }); }); diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 4b2e1b8bd4..719aee618d 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -355,7 +355,11 @@ export class SignalClient { return withAbort( withTimeout(self.processInitialSignalMessage(connection.value, isReconnect), 5_000), abortSignal, - ); + ).orTee((error) => { + if (error instanceof AbortError) { + self.sendLeave(); + } + }); }), this.connectionLock, ); diff --git a/src/api/WebSocketStream.test.ts b/src/api/WebSocketStream.test.ts index f5d11c15c5..6228fc3240 100644 --- a/src/api/WebSocketStream.test.ts +++ b/src/api/WebSocketStream.test.ts @@ -184,7 +184,7 @@ describe('WebSocketStream', () => { new WebSocketStream('wss://test.example.com', { signal: abortController.signal, }); - }).toThrow('This operation was aborted'); + }).toThrow('AbortError'); }); it('should close when abort signal is triggered', () => { From f2da6bbf4c06d65432c8edc58894b4c61104d7cb Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 21:22:38 +0100 Subject: [PATCH 09/58] more type fixes --- src/api/SignalClient.test.ts | 18 +++++++++++++----- src/api/SignalClient.ts | 22 +++++----------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index c4f593d554..3c4cfd79ce 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -8,9 +8,9 @@ import { import { Result, ResultAsync } from 'neverthrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionError, ConnectionErrorReason } from '../room/errors'; -import { SignalClient, SignalConnectionState, ValidationType } from './SignalClient'; -import { WebSocketCloseInfo, WebSocketConnection, WebSocketError } from './WebSocketStream'; -import { WebSocketStream } from './WebSocketStream'; +import { SignalClient, SignalConnectionState, type ValidationType } from './SignalClient'; +import type { WebSocketCloseInfo, WebSocketConnection } from './WebSocketStream'; +import { WebSocketError, WebSocketStream } from './WebSocketStream'; // Mock the WebSocketStream vi.mock('./WebSocketStream'); @@ -189,7 +189,11 @@ describe('SignalClient.connect', () => { websocketTimeout: 100, }; - const result = await signalClient.join('wss://test.livekit.io', 'test-token', shortTimeoutOptions); + const result = await signalClient.join( + 'wss://test.livekit.io', + 'test-token', + shortTimeoutOptions, + ); expect(result.isErr()).toBe(true); expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); @@ -485,7 +489,11 @@ describe('SignalClient.connect', () => { websocketTimeout: 100, }; - const result = await signalClient.join('wss://test.livekit.io', 'test-token', shortTimeoutOptions); + const result = await signalClient.join( + 'wss://test.livekit.io', + 'test-token', + shortTimeoutOptions, + ); expect(result.isErr()).toBe(true); const error = result._unsafeUnwrapErr(); diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 719aee618d..149c45da69 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -871,9 +871,7 @@ export class SignalClient { firstSignalResponse: SignalResponse, isReconnect: boolean, ): Result< - | { response: JoinResponse; shouldProcessFirstMessage: false } - | { response: ReconnectResponse; shouldProcessFirstMessage: false } - | { response: undefined; shouldProcessFirstMessage: true }, + ValidationType, // TODO, this should probably not be a ConnectionError? ConnectionError > { @@ -1082,17 +1080,7 @@ function createJoinRequestConnectionParams( return params; } -export type ValidationType = Reconnect extends false - ? { - response: JoinResponse; - shouldProcessFirstMessage: false; - } - : - | { - response: ReconnectResponse; - shouldProcessFirstMessage: false; - } - | { - response: undefined; - shouldProcessFirstMessage: true; - }; +export type ValidationType = + | { response: JoinResponse; shouldProcessFirstMessage: false } + | { response: ReconnectResponse; shouldProcessFirstMessage: false } + | { response: undefined; shouldProcessFirstMessage: true }; From 03cca2ec1efed0f09baf94a309dc9b0704bbcce9 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 23:00:02 +0100 Subject: [PATCH 10/58] better utils --- src/api/SignalClient.ts | 60 ++++++++--------- src/api/utils.ts | 141 +++++++++++++++++++++++++++------------- 2 files changed, 121 insertions(+), 80 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 149c45da69..98ab015e04 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -299,40 +299,31 @@ export class SignalClient { } self.ws = new WebSocketStream(rtcUrl); - self.ws.closed.match( - (closeInfo) => { - if (self.isEstablishingConnection) { - return err( - new ConnectionError( - `Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`, - ConnectionErrorReason.InternalError, - ), - ); - } - if (closeInfo.closeCode !== 1000) { - self.log.warn(`websocket closed`, { - ...self.logContext, - reason: closeInfo.reason, - code: closeInfo.closeCode, - wasClean: closeInfo.closeCode === 1000, - state: self.state, - }); - } - }, - (error) => { - if (self.isEstablishingConnection) { - return err( - new ConnectionError( - `Websocket error during a (re)connection attempt: ${error.message}`, - ConnectionErrorReason.InternalError, - ), - ); - } - }, - ); - + // Race the connection opening against the websocket closing const connection = await withAbort( - withTimeout(self.ws.opened, opts.websocketTimeout), + withTimeout(self.ws.opened, opts.websocketTimeout).orElse((openError) => { + // Check if the websocket closed/errored before opening completed + return ResultAsync.fromPromise( + Promise.race([ + // Return the close error if it resolves first + self.ws!.closed.match( + (closeInfo) => + new ConnectionError( + `Websocket closed during connection attempt: ${closeInfo.reason}`, + ConnectionErrorReason.InternalError, + ), + (closeError) => + new ConnectionError( + `Websocket error during connection attempt: ${closeError.message}`, + ConnectionErrorReason.InternalError, + ), + ), + // Otherwise return the original open error after a brief moment + sleep(10).then(() => openError), + ]), + (e) => e as WebSocketError | TimeoutError | ConnectionError, + ).andThen((finalError) => errAsync(finalError)); + }), abortSignal, ); @@ -344,7 +335,8 @@ export class SignalClient { withTimeout(self.handleConnectionError(error.message, validateUrl), 3_000), abortSignal, ); - self.close(undefined, error.type); + const closeReason = 'type' in error ? error.type : error.message; + self.close(undefined, closeReason); return connectionError; } // other errors, handle diff --git a/src/api/utils.ts b/src/api/utils.ts index 334f2231cd..6b3d58332b 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -54,81 +54,130 @@ export function getAbortReasonAsString( } export function withTimeout( - ra: ResultAsync | Promise>, + ra: ResultAsyncLike, ms: number, ): ResultAsync { - const toSettledPromise: PromiseLike = ra.then((res) => - // `res` is a Result; resolve with T or reject with E - res.match( - (v) => Promise.resolve(v), - (err) => Promise.reject(err), - ), - ); - - const timeout = new Promise((_, reject) => - setTimeout(() => { - console.warn('timeout triggered'); + const timeout = ResultAsync.fromPromise( + new Promise((_, reject) => + setTimeout(() => { + console.warn('timeout triggered'); - reject(new TimeoutError()); - }, ms), + reject(new TimeoutError()); + }, ms), + ), + (e) => e as TimeoutError, ); - return ResultAsync.fromPromise( - // race returns a Promise that resolves with T or rejects with E/onTimeout() - Promise.race([toSettledPromise, timeout]), - // map any thrown/rejected value into the error type E - (e) => e as E | TimeoutError, - ); + return raceResults([ra, timeout]); } export function withAbort( - ra: ResultAsync, + ra: ResultAsyncLike, signal: AbortSignal | undefined, ): ResultAsync { if (signal?.aborted) { return errAsync(new AbortError()); } - const abortPromise = new Promise((_, reject) => { - const onAbortHandler = () => { - signal?.removeEventListener('abort', onAbortHandler); - reject(new AbortError()); - }; - signal?.addEventListener('abort', onAbortHandler); - }); - - const toSettledPromise: PromiseLike = ra.then((res) => - res.match( - (v) => Promise.resolve(v), - (err) => Promise.reject(err), - ), + const abortResult = ResultAsync.fromPromise( + new Promise((_, reject) => { + const onAbortHandler = () => { + signal?.removeEventListener('abort', onAbortHandler); + reject(new AbortError()); + }; + signal?.addEventListener('abort', onAbortHandler); + }), + (e) => e as AbortError, ); - return ResultAsync.fromPromise( - Promise.race([toSettledPromise, abortPromise]), - (e) => e as E | AbortError, - ); + return raceResults([ra, abortResult]); } export function withMutex( - fn: ResultAsync | Result, + fn: ResultAsyncLike, mutex: Mutex, +): ResultAsync { + return ResultAsync.fromSafePromise(mutex.lock()).andThen((unlock) => withFinally(fn, unlock)); +} + +/** + * Executes a callback after a ResultAsync completes, regardless of success or failure. + * Similar to Promise.finally() but for ResultAsync. + * + * @param ra - The ResultAsync to execute + * @param onFinally - Callback to run after completion (receives no arguments) + * @returns A new ResultAsync with the same result, but runs onFinally first + * + * @example + * ```ts + * withFinally( + * someOperation(), + * () => cleanup() + * ) + * ``` + */ +export function withFinally( + ra: ResultAsyncLike, + onFinally: () => void | Promise, ): ResultAsync { return ResultAsync.fromPromise( (async () => { - const unlock = await mutex.lock(); try { - const res = await fn; - return res.match( - (v) => v, - (err) => { - throw err as Error; + const result = await ra; + return result.match( + (value) => value, + (error) => { + throw error as Error; }, ); + } catch (error) { + await onFinally(); + throw error as Error; } finally { - unlock(); + await onFinally(); } })(), (e) => e as E, ); } + +/** + * Races multiple ResultAsync operations and returns whichever completes first. + * If all fail, returns the error from the first one to reject. + * API-compatible with Promise.race, supporting heterogeneous types. + * + * @param values - Array of ResultAsync operations to race (can have different types) + * @returns A new ResultAsync with the result of whichever completes first + * + * @example + * ```ts + * // Race a connection attempt against a timeout + * raceResults([ + * connectToServer(), // ResultAsync + * delay(5000).andThen(() => errAsync(new TimeoutError())) // ResultAsync + * ]) // ResultAsync + * ``` + */ +export function raceResults[]>( + values: T, +): ResultAsync< + T[number] extends ResultAsync ? V : never, + T[number] extends ResultAsync ? E : never +> { + type V = T[number] extends ResultAsync ? Value : never; + type E = T[number] extends ResultAsync ? Err : never; + + const settledPromises = values.map( + (ra): PromiseLike => + ra.then((res) => + res.match( + (v) => Promise.resolve(v), + (err) => Promise.reject(err), + ), + ), + ); + + return ResultAsync.fromPromise(Promise.race(settledPromises), (e) => e as E); +} + +export type ResultAsyncLike = ResultAsync | Promise>; From b7583e3ab04d918724e2499a2f03e11337d22fc9 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 23:28:47 +0100 Subject: [PATCH 11/58] better? --- src/api/SignalClient.ts | 53 ++++++++++++++++++++++------------------- src/api/utils.ts | 2 -- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 98ab015e04..e4b3f83056 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -56,6 +56,7 @@ import { createRtcUrl, createValidateUrl, parseSignalResponse, + raceResults, withAbort, withMutex, withTimeout, @@ -301,29 +302,19 @@ export class SignalClient { // Race the connection opening against the websocket closing const connection = await withAbort( - withTimeout(self.ws.opened, opts.websocketTimeout).orElse((openError) => { - // Check if the websocket closed/errored before opening completed - return ResultAsync.fromPromise( - Promise.race([ - // Return the close error if it resolves first - self.ws!.closed.match( - (closeInfo) => - new ConnectionError( - `Websocket closed during connection attempt: ${closeInfo.reason}`, - ConnectionErrorReason.InternalError, - ), - (closeError) => - new ConnectionError( - `Websocket error during connection attempt: ${closeError.message}`, - ConnectionErrorReason.InternalError, - ), + raceResults([ + withTimeout(self.ws.opened, opts.websocketTimeout), + // Return the close promise as error if it resolves first + self.ws!.closed.andThen((info) => + err( + new ConnectionError( + info.reason ?? 'Websocket closed during (re)connection attempt', + ConnectionErrorReason.InternalError, ), - // Otherwise return the original open error after a brief moment - sleep(10).then(() => openError), - ]), - (e) => e as WebSocketError | TimeoutError | ConnectionError, - ).andThen((finalError) => errAsync(finalError)); - }), + ), + ), + ]), + abortSignal, ); @@ -335,7 +326,7 @@ export class SignalClient { withTimeout(self.handleConnectionError(error.message, validateUrl), 3_000), abortSignal, ); - const closeReason = 'type' in error ? error.type : error.message; + const closeReason = 'type' in error ? `${error.type}: ${error.message}` : error.message; self.close(undefined, closeReason); return connectionError; } @@ -345,7 +336,21 @@ export class SignalClient { } return withAbort( - withTimeout(self.processInitialSignalMessage(connection.value, isReconnect), 5_000), + withTimeout( + raceResults([ + self.processInitialSignalMessage(connection.value, isReconnect), + // Return the close promise as error if it resolves first + self.ws!.closed.andThen((info) => + err( + new ConnectionError( + info.reason ?? 'Websocket closed during (re)connection attempt', + ConnectionErrorReason.InternalError, + ), + ), + ), + ]), + 5_000, + ), abortSignal, ).orTee((error) => { if (error instanceof AbortError) { diff --git a/src/api/utils.ts b/src/api/utils.ts index 6b3d58332b..d8a30b4b09 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -60,8 +60,6 @@ export function withTimeout( const timeout = ResultAsync.fromPromise( new Promise((_, reject) => setTimeout(() => { - console.warn('timeout triggered'); - reject(new TimeoutError()); }, ms), ), From 6721075267bb7a630881cc1cbc0db1b9087244a6 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 23:36:27 +0100 Subject: [PATCH 12/58] Revert "add permissions to publish workflow" This reverts commit d920fa7a1b61571d8a1895648a54a3ba9983de3e. --- .github/workflows/release.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7b1d7c6a14..55f9b8ff62 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,11 +8,6 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} -permissions: - id-token: write # Required for OIDC - contents: write # Required to create GH releases - pull-requests: write # Required to interact with PRs - jobs: release: name: Release From b981fdd9dc0c4e4060b3fb0c5bfd6b724e1ea72e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 11 Nov 2025 14:09:07 +0100 Subject: [PATCH 13/58] add permissions to publish workflow --- .github/workflows/release.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 55f9b8ff62..7b1d7c6a14 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,6 +8,11 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} +permissions: + id-token: write # Required for OIDC + contents: write # Required to create GH releases + pull-requests: write # Required to interact with PRs + jobs: release: name: Release From e2ea5a44cbb26244974cb24251cad6983eb154db Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 23:45:27 +0100 Subject: [PATCH 14/58] minor improvements --- src/api/SignalClient.ts | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index e4b3f83056..bd432503e8 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -302,18 +302,30 @@ export class SignalClient { // Race the connection opening against the websocket closing const connection = await withAbort( - raceResults([ - withTimeout(self.ws.opened, opts.websocketTimeout), - // Return the close promise as error if it resolves first - self.ws!.closed.andThen((info) => - err( - new ConnectionError( - info.reason ?? 'Websocket closed during (re)connection attempt', - ConnectionErrorReason.InternalError, - ), - ), - ), - ]), + withTimeout( + raceResults([ + self.ws.opened, + // Return the close promise as error if it resolves first + self.ws!.closed.andThen((closeInfo) => { + if (closeInfo.closeCode !== 1000) { + self.log.warn(`websocket closed`, { + ...self.logContext, + reason: closeInfo.reason, + code: closeInfo.closeCode, + wasClean: closeInfo.closeCode === 1000, + state: self.state, + }); + } + return err( + new ConnectionError( + closeInfo.reason ?? 'Websocket closed during (re)connection attempt', + ConnectionErrorReason.InternalError, + ), + ); + }), + ]), + opts.websocketTimeout, + ), abortSignal, ); @@ -323,7 +335,7 @@ export class SignalClient { if (self.state !== SignalConnectionState.CONNECTED) { self.state = SignalConnectionState.DISCONNECTED; const connectionError = await withAbort( - withTimeout(self.handleConnectionError(error.message, validateUrl), 3_000), + withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), abortSignal, ); const closeReason = 'type' in error ? `${error.type}: ${error.message}` : error.message; @@ -929,7 +941,7 @@ export class SignalClient { * @returns A ConnectionError with appropriate reason and status * @internal */ - private async handleConnectionError( + private async fetchErrorInfo( reason: unknown, validateUrl: string, ): Promise> { From 762e54e938988f3305a2ad6c446f9235a3b1985c Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 12 Nov 2025 23:49:25 +0100 Subject: [PATCH 15/58] clean close --- src/api/SignalClient.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index bd432503e8..6f32a30432 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -366,7 +366,13 @@ export class SignalClient { abortSignal, ).orTee((error) => { if (error instanceof AbortError) { - self.sendLeave(); + self + .sendLeave() + .then(() => self.close()) + .catch((e) => { + self.log.error(e); + self.close(); + }); } }); }), From 2ae76ff7744826c4003721a5a97ff94debed715f Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 13 Nov 2025 12:02:09 +0100 Subject: [PATCH 16/58] fix reconnect response handling --- src/api/SignalClient.ts | 16 +++++++++++----- src/api/WebSocketStream.ts | 6 +----- src/room/RTCEngine.ts | 12 ++++++------ src/room/Room.ts | 5 ----- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 6f32a30432..219c77c8b4 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -298,16 +298,21 @@ export class SignalClient { if (self.ws) { await self.close(false); } - self.ws = new WebSocketStream(rtcUrl); + const ws = new WebSocketStream(rtcUrl); + self.ws = ws; // Race the connection opening against the websocket closing const connection = await withAbort( withTimeout( raceResults([ - self.ws.opened, + ws.opened, // Return the close promise as error if it resolves first - self.ws!.closed.andThen((closeInfo) => { - if (closeInfo.closeCode !== 1000) { + ws.closed.andThen((closeInfo) => { + if ( + closeInfo.closeCode !== 1000 && + // we only log the waring here if the ws connection is still the same, we don't care about closing of older ws connections that have been replaced + ws === self.ws + ) { self.log.warn(`websocket closed`, { ...self.logContext, reason: closeInfo.reason, @@ -352,7 +357,7 @@ export class SignalClient { raceResults([ self.processInitialSignalMessage(connection.value, isReconnect), // Return the close promise as error if it resolves first - self.ws!.closed.andThen((info) => + ws!.closed.andThen((info) => err( new ConnectionError( info.reason ?? 'Websocket closed during (re)connection attempt', @@ -434,6 +439,7 @@ export class SignalClient { this.ws = undefined; this.streamWriter = undefined; await Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]); + this.log.info('closed websocket', { reason }); } } catch (e) { this.log.debug('websocket error while closing', { ...this.logContext, error: e }); diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index 2598100409..7290ff4914 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -54,14 +54,10 @@ export class WebSocketStream ws.close(code, reason); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index d77052ee07..ee0e70a7fb 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1115,7 +1115,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.log.info(`resuming signal connection, attempt ${this.reconnectAttempts}`, this.logContext); this.emit(EngineEvent.Resuming); - let res: ReconnectResponse | undefined; this.setupSignalClientCallbacks(); const reconnectResult = await this.client.reconnect( this.url, @@ -1129,11 +1128,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.emit(EngineEvent.SignalResumed); - if (res) { - const rtcConfig = this.makeRTCConfiguration(res); + const reconnectResponse = reconnectResult.value; + if (reconnectResponse) { + const rtcConfig = this.makeRTCConfiguration(reconnectResponse); this.pcManager.updateConfiguration(rtcConfig); if (this.latestJoinResponse) { - this.latestJoinResponse.serverInfo = res.serverInfo; + this.latestJoinResponse.serverInfo = reconnectResponse.serverInfo; } } else { this.log.warn('Did not receive reconnect response', this.logContext); @@ -1161,8 +1161,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.createDataChannels(); } - if (res?.lastMessageSeq) { - this.resendReliableMessagesForResume(res.lastMessageSeq); + if (reconnectResponse?.lastMessageSeq) { + this.resendReliableMessagesForResume(reconnectResponse.lastMessageSeq); } // resume success diff --git a/src/room/Room.ts b/src/room/Room.ts index 69eca66a27..7b6e251e72 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1922,7 +1922,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) }); if (byteLength(response) > MAX_PAYLOAD_BYTES) { responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); - console.warn(`RPC Response payload too large for ${method}`); } else { responsePayload = response; } @@ -1930,10 +1929,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (error instanceof RpcError) { responseError = error; } else { - console.warn( - `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, - error, - ); responseError = RpcError.builtIn('APPLICATION_ERROR'); } } From e28354a8f5b817bf7dfef0d689c675b9572c6875 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 13 Nov 2025 13:40:09 +0100 Subject: [PATCH 17/58] more idiomatic yield --- src/api/SignalClient.ts | 120 +++++++++++++++++++--------------------- 1 file changed, 57 insertions(+), 63 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 219c77c8b4..27ea04af9c 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -302,74 +302,68 @@ export class SignalClient { self.ws = ws; // Race the connection opening against the websocket closing - const connection = await withAbort( - withTimeout( - raceResults([ - ws.opened, - // Return the close promise as error if it resolves first - ws.closed.andThen((closeInfo) => { - if ( - closeInfo.closeCode !== 1000 && - // we only log the waring here if the ws connection is still the same, we don't care about closing of older ws connections that have been replaced - ws === self.ws - ) { - self.log.warn(`websocket closed`, { - ...self.logContext, - reason: closeInfo.reason, - code: closeInfo.closeCode, - wasClean: closeInfo.closeCode === 1000, - state: self.state, - }); - } - return err( - new ConnectionError( - closeInfo.reason ?? 'Websocket closed during (re)connection attempt', - ConnectionErrorReason.InternalError, - ), - ); - }), - ]), - opts.websocketTimeout, - ), - - abortSignal, + const connectionOrClose = raceResults([ + ws.opened, + // Return the close promise as error if it resolves first + ws.closed.andThen((closeInfo) => { + if ( + closeInfo.closeCode !== 1000 && + // we only log the waring here if the ws connection is still the same, we don't care about closing of older ws connections that have been replaced + ws === self.ws + ) { + self.log.warn(`websocket closed`, { + ...self.logContext, + reason: closeInfo.reason, + code: closeInfo.closeCode, + wasClean: closeInfo.closeCode === 1000, + state: self.state, + }); + } + return err( + new ConnectionError( + closeInfo.reason ?? 'Websocket closed during (re)connection attempt', + ConnectionErrorReason.InternalError, + ), + ); + }), + ]); + + const connectionResult = withTimeout(connectionOrClose, opts.websocketTimeout).mapErr( + async (error) => { + // retrieve info about what error was causing this and enhance the returned error + if (self.state !== SignalConnectionState.CONNECTED) { + self.state = SignalConnectionState.DISCONNECTED; + const connectionError = await withAbort( + withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), + abortSignal, + ); + const closeReason = + 'type' in error ? `${error.type}: ${error.message}` : error.message; + self.close(undefined, closeReason); + if (connectionError.isErr()) { + return connectionError.error; + } + } + return error; + }, ); - if (connection.isErr()) { - const error = connection.error; - if (self.state !== SignalConnectionState.CONNECTED) { - self.state = SignalConnectionState.DISCONNECTED; - const connectionError = await withAbort( - withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), - abortSignal, - ); - const closeReason = 'type' in error ? `${error.type}: ${error.message}` : error.message; - self.close(undefined, closeReason); - return connectionError; - } - // other errors, handle - self.handleWSError(error); - return errAsync(error); - } + const connection = yield* withAbort(connectionResult, abortSignal); - return withAbort( - withTimeout( - raceResults([ - self.processInitialSignalMessage(connection.value, isReconnect), - // Return the close promise as error if it resolves first - ws!.closed.andThen((info) => - err( - new ConnectionError( - info.reason ?? 'Websocket closed during (re)connection attempt', - ConnectionErrorReason.InternalError, - ), - ), + const firstMessageOrClose = raceResults([ + self.processInitialSignalMessage(connection, isReconnect), + // Return the close promise as error if it resolves first + ws!.closed.andThen((info) => + err( + new ConnectionError( + info.reason ?? 'Websocket closed during (re)connection attempt', + ConnectionErrorReason.InternalError, ), - ]), - 5_000, + ), ), - abortSignal, - ).orTee((error) => { + ]); + + return withAbort(withTimeout(firstMessageOrClose, 5_000), abortSignal).orTee((error) => { if (error instanceof AbortError) { self .sendLeave() From 6243d2bc473ad9c0acaeaa7569c6284e24933d01 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 13 Nov 2025 13:57:05 +0100 Subject: [PATCH 18/58] fix build --- src/api/SignalClient.ts | 75 +++++++++++------------- src/connectionHelper/checks/turn.ts | 5 +- src/connectionHelper/checks/websocket.ts | 16 ++--- 3 files changed, 47 insertions(+), 49 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 27ea04af9c..90f8bbfc34 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -301,44 +301,19 @@ export class SignalClient { const ws = new WebSocketStream(rtcUrl); self.ws = ws; - // Race the connection opening against the websocket closing - const connectionOrClose = raceResults([ - ws.opened, - // Return the close promise as error if it resolves first - ws.closed.andThen((closeInfo) => { - if ( - closeInfo.closeCode !== 1000 && - // we only log the waring here if the ws connection is still the same, we don't care about closing of older ws connections that have been replaced - ws === self.ws - ) { - self.log.warn(`websocket closed`, { - ...self.logContext, - reason: closeInfo.reason, - code: closeInfo.closeCode, - wasClean: closeInfo.closeCode === 1000, - state: self.state, - }); - } - return err( - new ConnectionError( - closeInfo.reason ?? 'Websocket closed during (re)connection attempt', - ConnectionErrorReason.InternalError, - ), - ); - }), - ]); - - const connectionResult = withTimeout(connectionOrClose, opts.websocketTimeout).mapErr( + const wsConnectionResult = withTimeout(self.ws.opened, opts.websocketTimeout).mapErr( async (error) => { - // retrieve info about what error was causing this and enhance the returned error + // retrieve info about what error was causing the connection failure and enhance the returned error if (self.state !== SignalConnectionState.CONNECTED) { self.state = SignalConnectionState.DISCONNECTED; const connectionError = await withAbort( withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), abortSignal, ); - const closeReason = - 'type' in error ? `${error.type}: ${error.message}` : error.message; + // const closeReason = + // 'type' in error ? `${error.type}: ${error.message}` : error.message; + const closeReason = `${error.type}: ${error.message}`; + self.close(undefined, closeReason); if (connectionError.isErr()) { return connectionError.error; @@ -348,19 +323,37 @@ export class SignalClient { }, ); - const connection = yield* withAbort(connectionResult, abortSignal); + const wsConnection = yield* withAbort(wsConnectionResult, abortSignal); const firstMessageOrClose = raceResults([ - self.processInitialSignalMessage(connection, isReconnect), + self.processInitialSignalMessage(wsConnection, isReconnect), // Return the close promise as error if it resolves first - ws!.closed.andThen((info) => - err( - new ConnectionError( - info.reason ?? 'Websocket closed during (re)connection attempt', - ConnectionErrorReason.InternalError, - ), - ), - ), + ws!.closed + .andThen((closeInfo) => { + if ( + closeInfo.closeCode !== 1000 && + // we only log the warning here if the current ws connection is still the same, we don't care about closing of older ws connections that have been replaced + ws === self.ws + ) { + self.log.warn(`websocket closed`, { + ...self.logContext, + reason: closeInfo.reason, + code: closeInfo.closeCode, + wasClean: closeInfo.closeCode === 1000, + state: self.state, + }); + } + + return err( + new ConnectionError( + closeInfo.reason ?? 'Websocket closed during (re)connection attempt', + ConnectionErrorReason.InternalError, + ), + ); + }) + .orTee((error) => { + self.handleWSError(error); + }), ]); return withAbort(withTimeout(firstMessageOrClose, 5_000), abortSignal).orTee((error) => { diff --git a/src/connectionHelper/checks/turn.ts b/src/connectionHelper/checks/turn.ts index 3aef7f05e1..4eabb6fa22 100644 --- a/src/connectionHelper/checks/turn.ts +++ b/src/connectionHelper/checks/turn.ts @@ -8,7 +8,7 @@ export class TURNCheck extends Checker { async perform(): Promise { const signalClient = new SignalClient(); - const joinRes = await signalClient.join(this.url, this.token, { + const joinResult = await signalClient.join(this.url, this.token, { autoSubscribe: true, maxRetries: 0, e2eeEnabled: false, @@ -16,6 +16,9 @@ export class TURNCheck extends Checker { singlePeerConnection: false, }); + // TODO fix unsafe usage + const joinRes = joinResult._unsafeUnwrap(); + let hasTLS = false; let hasTURN = false; let hasSTUN = false; diff --git a/src/connectionHelper/checks/websocket.ts b/src/connectionHelper/checks/websocket.ts index ab9afa34d3..40592e4617 100644 --- a/src/connectionHelper/checks/websocket.ts +++ b/src/connectionHelper/checks/websocket.ts @@ -13,13 +13,15 @@ export class WebSocketCheck extends Checker { } let signalClient = new SignalClient(); - const joinRes = await signalClient.join(this.url, this.token, { - autoSubscribe: true, - maxRetries: 0, - e2eeEnabled: false, - websocketTimeout: 15_000, - singlePeerConnection: false, - }); + const joinRes = ( + await signalClient.join(this.url, this.token, { + autoSubscribe: true, + maxRetries: 0, + e2eeEnabled: false, + websocketTimeout: 15_000, + singlePeerConnection: false, + }) + )._unsafeUnwrap(); this.appendMessage(`Connected to server, version ${joinRes.serverVersion}.`); if (joinRes.serverInfo?.edition === ServerInfo_Edition.Cloud && joinRes.serverInfo?.region) { this.appendMessage(`LiveKit Cloud: ${joinRes.serverInfo?.region}`); From 263b9fc4b42201cedf7e4c2b97f6db655e27973e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 13 Nov 2025 14:21:36 +0100 Subject: [PATCH 19/58] handle abort and debug logs --- src/api/SignalClient.ts | 134 +++++++++++++++++++++++++++++++++---- src/api/WebSocketStream.ts | 6 ++ 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 90f8bbfc34..a5f1e59151 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -276,15 +276,22 @@ export class SignalClient { U extends T extends false ? JoinResponse : ReconnectResponse | undefined, >(url: string, token: string, isReconnect: T, opts: ConnectOpts, abortSignal?: AbortSignal) { const self = this; + const connectLabel = `SignalClient.connect [${isReconnect ? 'reconnect' : 'join'}]`; + console.profile(connectLabel); + console.time(connectLabel); + return withMutex( safeTry(async function* () { self.connectOptions = opts; + + console.time(`${connectLabel} - URL setup`); const clientInfo = getClientInfo(); const params = opts.singlePeerConnection ? createJoinRequestConnectionParams(token, clientInfo, opts, isReconnect) : createConnectionParams(token, clientInfo, opts, isReconnect); const rtcUrl = createRtcUrl(url, params); const validateUrl = createValidateUrl(rtcUrl); + console.timeEnd(`${connectLabel} - URL setup`); const redactedUrl = new URL(rtcUrl); if (redactedUrl.searchParams.has('access_token')) { @@ -295,27 +302,44 @@ export class SignalClient { reconnectReason: opts.reconnectReason, ...self.logContext, }); + if (self.ws) { + console.time(`${connectLabel} - Close existing WS`); await self.close(false); + console.timeEnd(`${connectLabel} - Close existing WS`); } + + console.time(`${connectLabel} - Create WebSocket`); const ws = new WebSocketStream(rtcUrl); self.ws = ws; + console.timeEnd(`${connectLabel} - Create WebSocket`); + console.time(`${connectLabel} - WebSocket opened`); const wsConnectionResult = withTimeout(self.ws.opened, opts.websocketTimeout).mapErr( async (error) => { + console.timeEnd(`${connectLabel} - WebSocket opened`); + console.error(`[${connectLabel}] WebSocket connection failed:`, error); + console.trace(`[${connectLabel}] WebSocket error stack trace`); + // retrieve info about what error was causing the connection failure and enhance the returned error if (self.state !== SignalConnectionState.CONNECTED) { self.state = SignalConnectionState.DISCONNECTED; + console.time(`${connectLabel} - Fetch error info`); const connectionError = await withAbort( withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), abortSignal, ); + console.timeEnd(`${connectLabel} - Fetch error info`); // const closeReason = // 'type' in error ? `${error.type}: ${error.message}` : error.message; const closeReason = `${error.type}: ${error.message}`; self.close(undefined, closeReason); if (connectionError.isErr()) { + console.error( + `[${connectLabel}] Connection error after fetch:`, + connectionError.error, + ); return connectionError.error; } } @@ -323,13 +347,23 @@ export class SignalClient { }, ); - const wsConnection = yield* withAbort(wsConnectionResult, abortSignal); + const wsConnection = yield* withAbort(wsConnectionResult, abortSignal).orTee((error) => { + self.close(undefined, error.message); + }); + console.timeEnd(`${connectLabel} - WebSocket opened`); + console.time(`${connectLabel} - First message or close`); const firstMessageOrClose = raceResults([ self.processInitialSignalMessage(wsConnection, isReconnect), // Return the close promise as error if it resolves first ws!.closed .andThen((closeInfo) => { + console.warn(`[${connectLabel}] WebSocket closed during connection:`, { + reason: closeInfo.reason, + code: closeInfo.closeCode, + wasClean: closeInfo.closeCode === 1000, + }); + if ( closeInfo.closeCode !== 1000 && // we only log the warning here if the current ws connection is still the same, we don't care about closing of older ws connections that have been replaced @@ -352,21 +386,37 @@ export class SignalClient { ); }) .orTee((error) => { + console.error(`[${connectLabel}] WebSocket error during connection:`, error); + console.trace(`[${connectLabel}] WebSocket error trace`); self.handleWSError(error); }), ]); - return withAbort(withTimeout(firstMessageOrClose, 5_000), abortSignal).orTee((error) => { - if (error instanceof AbortError) { - self - .sendLeave() - .then(() => self.close()) - .catch((e) => { - self.log.error(e); - self.close(); - }); - } - }); + const result = withAbort(withTimeout(firstMessageOrClose, 5_000), abortSignal).orTee( + (error) => { + console.error(`[${connectLabel}] Error during first message or close:`, error); + console.trace(`[${connectLabel}] Error trace`); + console.timeEnd(`${connectLabel} - First message or close`); + console.timeEnd(connectLabel); + console.profileEnd(connectLabel); + + if (error instanceof AbortError) { + self + .sendLeave() + .then(() => self.close()) + .catch((e) => { + self.log.error(e); + self.close(); + }); + } + }, + ); + + console.timeEnd(`${connectLabel} - First message or close`); + console.timeEnd(connectLabel); + console.profileEnd(connectLabel); + + return result; }), this.connectionLock, ); @@ -828,13 +878,24 @@ export class SignalClient { U extends T extends false ? JoinResponse : ReconnectResponse | undefined, >(connection: WebSocketConnection, isReconnect: T): ResultAsync { const self = this; + const processLabel = `SignalClient.processInitialSignalMessage [${isReconnect ? 'reconnect' : 'join'}]`; + console.time(processLabel); + // TODO: This should be more granular here than ConnectionError return safeTry(async function* () { + console.time(`${processLabel} - Setup readers/writers`); const signalReader = connection.readable.getReader(); self.streamWriter = connection.writable.getWriter(); + console.timeEnd(`${processLabel} - Setup readers/writers`); + + console.time(`${processLabel} - Read first message`); const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); + console.timeEnd(`${processLabel} - Read first message`); if (!firstMessage.value) { + console.error(`[${processLabel}] No message received as first message`); + console.trace(`[${processLabel}] No first message trace`); + console.timeEnd(processLabel); return err( new ConnectionError( 'no message received as first message', @@ -843,14 +904,25 @@ export class SignalClient { ); } + console.time(`${processLabel} - Parse first message`); const firstSignalResponse = parseSignalResponse(firstMessage.value); + console.log(`[${processLabel}] First message type:`, firstSignalResponse.message?.case); + console.timeEnd(`${processLabel} - Parse first message`); + // Validate the first message + console.time(`${processLabel} - Validate first message`); const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); + console.timeEnd(`${processLabel} - Validate first message`); + // Handle join response - set up ping configuration if (firstSignalResponse.message?.case === 'join') { self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { + console.log(`[${processLabel}] Ping config:`, { + timeout: self.pingTimeoutDuration, + interval: self.pingIntervalDuration, + }); self.log.debug('ping config', { ...self.logContext, timeout: self.pingTimeoutDuration, @@ -860,10 +932,14 @@ export class SignalClient { } // Handle successful connection + console.time(`${processLabel} - Handle signal connected`); const firstMessageToProcess = validation.shouldProcessFirstMessage ? firstSignalResponse : undefined; self.handleSignalConnected(connection, firstMessageToProcess); + console.timeEnd(`${processLabel} - Handle signal connected`); + console.timeEnd(processLabel); + return okAsync(validation.response as U); }); } @@ -883,7 +959,14 @@ export class SignalClient { // TODO, this should probably not be a ConnectionError? ConnectionError > { + console.log('[SignalClient.validateFirstMessage]', { + messageCase: firstSignalResponse.message?.case, + isReconnect, + state: this.state, + }); + if (isReconnect === false && firstSignalResponse.message?.case === 'join') { + console.log('[SignalClient.validateFirstMessage] Valid join response'); return ok({ response: firstSignalResponse.message.value, shouldProcessFirstMessage: false, @@ -894,12 +977,14 @@ export class SignalClient { firstSignalResponse.message?.case !== 'leave' ) { if (firstSignalResponse.message?.case === 'reconnect') { + console.log('[SignalClient.validateFirstMessage] Valid reconnect response'); return ok({ response: firstSignalResponse.message.value, shouldProcessFirstMessage: false, }); } else { // in reconnecting, any message received means signal reconnected and we still need to process it + console.log('[SignalClient.validateFirstMessage] Reconnected without reconnect response'); this.log.debug( 'declaring signal reconnected without reconnect response received', this.logContext, @@ -910,6 +995,13 @@ export class SignalClient { }); } } else if (this.isEstablishingConnection && firstSignalResponse.message?.case === 'leave') { + console.error( + '[SignalClient.validateFirstMessage] Received leave request during connection', + { + leaveReason: firstSignalResponse.message.value.reason, + }, + ); + console.trace('[SignalClient.validateFirstMessage] Leave request stack trace'); return err( new ConnectionError( 'Received leave request while trying to (re)connect', @@ -920,6 +1012,11 @@ export class SignalClient { ); } else if (!isReconnect) { // non-reconnect case, should receive join response first + console.error( + '[SignalClient.validateFirstMessage] Expected join, got:', + firstSignalResponse.message?.case, + ); + console.trace('[SignalClient.validateFirstMessage] Unexpected message trace'); return err( new ConnectionError( `did not receive join response, got ${firstSignalResponse.message?.case} instead`, @@ -928,6 +1025,8 @@ export class SignalClient { ); } + console.error('[SignalClient.validateFirstMessage] Unexpected first message'); + console.trace('[SignalClient.validateFirstMessage] Unexpected message trace'); return err( new ConnectionError('Unexpected first message', ConnectionErrorReason.InternalError), ); @@ -944,14 +1043,23 @@ export class SignalClient { reason: unknown, validateUrl: string, ): Promise> { + console.log('[SignalClient.fetchErrorInfo] Fetching error info:', { reason, validateUrl }); try { const resp = await fetch(validateUrl); + console.log('[SignalClient.fetchErrorInfo] Validation response:', { + status: resp.status, + statusText: resp.statusText, + }); + if (resp.status.toFixed(0).startsWith('4')) { const msg = await resp.text(); + console.error('[SignalClient.fetchErrorInfo] 4xx error:', msg); return err(new ConnectionError(msg, ConnectionErrorReason.NotAllowed, resp.status)); } else if (reason instanceof ConnectionError) { + console.error('[SignalClient.fetchErrorInfo] ConnectionError:', reason); return err(reason); } else { + console.error('[SignalClient.fetchErrorInfo] Unknown WebSocket error:', reason); return err( new ConnectionError( `Encountered unknown websocket error during connection: ${reason}`, @@ -961,6 +1069,8 @@ export class SignalClient { ); } } catch (e) { + console.error('[SignalClient.fetchErrorInfo] Fetch failed:', e); + console.trace('[SignalClient.fetchErrorInfo] Fetch error trace'); return err( e instanceof ConnectionError ? e diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index 7290ff4914..2f3ff057ec 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -72,6 +72,10 @@ export class WebSocketStream { + reject(new WebSocketError(`WS closed during connection establishment: ${ev.reason}`)); + }; + const openHandler = () => { resolve({ readable: new ReadableStream({ @@ -94,12 +98,14 @@ export class WebSocketStream error as WebSocketError, ); From d47b9249f46211993ac1aac6a7c38473a2a6fe54 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 13 Nov 2025 15:11:55 +0100 Subject: [PATCH 20/58] remove debug logs --- src/api/SignalClient.ts | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index a5f1e59151..71a6582f83 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -277,21 +277,17 @@ export class SignalClient { >(url: string, token: string, isReconnect: T, opts: ConnectOpts, abortSignal?: AbortSignal) { const self = this; const connectLabel = `SignalClient.connect [${isReconnect ? 'reconnect' : 'join'}]`; - console.profile(connectLabel); - console.time(connectLabel); return withMutex( safeTry(async function* () { self.connectOptions = opts; - console.time(`${connectLabel} - URL setup`); const clientInfo = getClientInfo(); const params = opts.singlePeerConnection ? createJoinRequestConnectionParams(token, clientInfo, opts, isReconnect) : createConnectionParams(token, clientInfo, opts, isReconnect); const rtcUrl = createRtcUrl(url, params); const validateUrl = createValidateUrl(rtcUrl); - console.timeEnd(`${connectLabel} - URL setup`); const redactedUrl = new URL(rtcUrl); if (redactedUrl.searchParams.has('access_token')) { @@ -304,32 +300,24 @@ export class SignalClient { }); if (self.ws) { - console.time(`${connectLabel} - Close existing WS`); await self.close(false); - console.timeEnd(`${connectLabel} - Close existing WS`); } - console.time(`${connectLabel} - Create WebSocket`); const ws = new WebSocketStream(rtcUrl); self.ws = ws; - console.timeEnd(`${connectLabel} - Create WebSocket`); - console.time(`${connectLabel} - WebSocket opened`); const wsConnectionResult = withTimeout(self.ws.opened, opts.websocketTimeout).mapErr( async (error) => { - console.timeEnd(`${connectLabel} - WebSocket opened`); console.error(`[${connectLabel}] WebSocket connection failed:`, error); console.trace(`[${connectLabel}] WebSocket error stack trace`); // retrieve info about what error was causing the connection failure and enhance the returned error if (self.state !== SignalConnectionState.CONNECTED) { self.state = SignalConnectionState.DISCONNECTED; - console.time(`${connectLabel} - Fetch error info`); const connectionError = await withAbort( withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), abortSignal, ); - console.timeEnd(`${connectLabel} - Fetch error info`); // const closeReason = // 'type' in error ? `${error.type}: ${error.message}` : error.message; const closeReason = `${error.type}: ${error.message}`; @@ -350,9 +338,7 @@ export class SignalClient { const wsConnection = yield* withAbort(wsConnectionResult, abortSignal).orTee((error) => { self.close(undefined, error.message); }); - console.timeEnd(`${connectLabel} - WebSocket opened`); - console.time(`${connectLabel} - First message or close`); const firstMessageOrClose = raceResults([ self.processInitialSignalMessage(wsConnection, isReconnect), // Return the close promise as error if it resolves first @@ -394,12 +380,6 @@ export class SignalClient { const result = withAbort(withTimeout(firstMessageOrClose, 5_000), abortSignal).orTee( (error) => { - console.error(`[${connectLabel}] Error during first message or close:`, error); - console.trace(`[${connectLabel}] Error trace`); - console.timeEnd(`${connectLabel} - First message or close`); - console.timeEnd(connectLabel); - console.profileEnd(connectLabel); - if (error instanceof AbortError) { self .sendLeave() @@ -412,10 +392,6 @@ export class SignalClient { }, ); - console.timeEnd(`${connectLabel} - First message or close`); - console.timeEnd(connectLabel); - console.profileEnd(connectLabel); - return result; }), this.connectionLock, @@ -879,23 +855,14 @@ export class SignalClient { >(connection: WebSocketConnection, isReconnect: T): ResultAsync { const self = this; const processLabel = `SignalClient.processInitialSignalMessage [${isReconnect ? 'reconnect' : 'join'}]`; - console.time(processLabel); // TODO: This should be more granular here than ConnectionError return safeTry(async function* () { - console.time(`${processLabel} - Setup readers/writers`); const signalReader = connection.readable.getReader(); self.streamWriter = connection.writable.getWriter(); - console.timeEnd(`${processLabel} - Setup readers/writers`); - console.time(`${processLabel} - Read first message`); const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); - console.timeEnd(`${processLabel} - Read first message`); - if (!firstMessage.value) { - console.error(`[${processLabel}] No message received as first message`); - console.trace(`[${processLabel}] No first message trace`); - console.timeEnd(processLabel); return err( new ConnectionError( 'no message received as first message', @@ -904,15 +871,10 @@ export class SignalClient { ); } - console.time(`${processLabel} - Parse first message`); const firstSignalResponse = parseSignalResponse(firstMessage.value); - console.log(`[${processLabel}] First message type:`, firstSignalResponse.message?.case); - console.timeEnd(`${processLabel} - Parse first message`); // Validate the first message - console.time(`${processLabel} - Validate first message`); const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); - console.timeEnd(`${processLabel} - Validate first message`); // Handle join response - set up ping configuration if (firstSignalResponse.message?.case === 'join') { @@ -932,13 +894,10 @@ export class SignalClient { } // Handle successful connection - console.time(`${processLabel} - Handle signal connected`); const firstMessageToProcess = validation.shouldProcessFirstMessage ? firstSignalResponse : undefined; self.handleSignalConnected(connection, firstMessageToProcess); - console.timeEnd(`${processLabel} - Handle signal connected`); - console.timeEnd(processLabel); return okAsync(validation.response as U); }); From e6a6332d449435875690bdd6edac0b4f1afdf1a3 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 14 Nov 2025 12:22:11 +0100 Subject: [PATCH 21/58] error helpers --- src/api/SignalClient.test.ts | 14 ++- src/api/SignalClient.ts | 49 ++++------- src/api/WebSocketStream.ts | 29 ++++--- src/api/utils.ts | 16 ++-- src/room/PCTransportManager.ts | 23 +---- src/room/RTCEngine.ts | 18 +--- src/room/RegionUrlProvider.ts | 23 ++--- src/room/Room.ts | 17 ++-- src/room/errors.ts | 151 +++++++++++++++++++++++++++------ 9 files changed, 196 insertions(+), 144 deletions(-) diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index 3c4cfd79ce..d5afc42d3c 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -340,7 +340,7 @@ describe('SignalClient.connect', () => { it('should reject with NotAllowed error for 4xx HTTP status', async () => { // eslint-disable-next-line neverthrow/must-use-result const opened = ResultAsync.fromPromise( - Promise.reject(new WebSocketError('Connection failed')), + Promise.reject(ConnectionError.websocket('Connection failed')), (error) => error as WebSocketError, ); mockWebSocketStream({ @@ -367,7 +367,7 @@ describe('SignalClient.connect', () => { it('should reject with ServerUnreachable when fetch fails', async () => { // eslint-disable-next-line neverthrow/must-use-result const opened = ResultAsync.fromPromise( - Promise.reject(new WebSocketError('Connection failed')), + Promise.reject(ConnectionError.websocket('Connection failed')), (error) => error as WebSocketError, ); mockWebSocketStream({ @@ -387,7 +387,7 @@ describe('SignalClient.connect', () => { }); it('should handle WebsocketError from WebSocket rejection', async () => { - const customError = new WebSocketError('Custom error'); + const customError = ConnectionError.websocket('Custom error'); // eslint-disable-next-line neverthrow/must-use-result const opened = ResultAsync.fromPromise( @@ -759,11 +759,7 @@ describe('SignalClient.handleConnectionError', () => { }); it('should return ConnectionError as-is if it is already a ConnectionError', async () => { - const connectionError = new ConnectionError( - 'Custom error', - ConnectionErrorReason.InternalError, - 500, - ); + const connectionError = ConnectionError.internal('Custom error'); (global.fetch as any).mockResolvedValueOnce({ status: 500, @@ -820,7 +816,7 @@ describe('SignalClient.handleConnectionError', () => { }); it('should handle fetch throwing ConnectionError', async () => { - const fetchError = new ConnectionError('Fetch failed', ConnectionErrorReason.ServerUnreachable); + const fetchError = ConnectionError.serverUnreachable('Fetch failed'); (global.fetch as any).mockRejectedValueOnce(fetchError); const handleMethod = (signalClient as any).handleConnectionError; diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 71a6582f83..c1b4b09236 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -46,12 +46,12 @@ import { } from '@livekit/protocol'; import { Result, ResultAsync, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; import log, { LoggerNames, getLogger } from '../logger'; -import { AbortError, ConnectionError, ConnectionErrorReason, TimeoutError } from '../room/errors'; +import { ConnectionError, ConnectionErrorReason } from '../room/errors'; import CriticalTimers from '../room/timers'; import type { LoggerOptions } from '../room/types'; import { getClientInfo, isReactNative, sleep } from '../room/utils'; import { AsyncQueue } from '../utils/AsyncQueue'; -import { type WebSocketConnection, WebSocketError, WebSocketStream } from './WebSocketStream'; +import { type WebSocketConnection, WebSocketStream } from './WebSocketStream'; import { createRtcUrl, createValidateUrl, @@ -254,10 +254,7 @@ export class SignalClient { reconnect(url: string, token: string, sid?: string, reason?: ReconnectReason) { if (!this.options) { return errAsync( - new ConnectionError( - 'attempted to reconnect without signal options being set', - ConnectionErrorReason.InternalError, - ), + ConnectionError.internal('attempted to reconnect without signal options being set'), ); } this.state = SignalConnectionState.RECONNECTING; @@ -279,7 +276,7 @@ export class SignalClient { const connectLabel = `SignalClient.connect [${isReconnect ? 'reconnect' : 'join'}]`; return withMutex( - safeTry(async function* () { + safeTry(async function* () { self.connectOptions = opts; const clientInfo = getClientInfo(); @@ -318,9 +315,8 @@ export class SignalClient { withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), abortSignal, ); - // const closeReason = - // 'type' in error ? `${error.type}: ${error.message}` : error.message; - const closeReason = `${error.type}: ${error.message}`; + + const closeReason = `${error.reason}: ${error.message}`; self.close(undefined, closeReason); if (connectionError.isErr()) { @@ -365,9 +361,8 @@ export class SignalClient { } return err( - new ConnectionError( + ConnectionError.internal( closeInfo.reason ?? 'Websocket closed during (re)connection attempt', - ConnectionErrorReason.InternalError, ), ); }) @@ -380,7 +375,7 @@ export class SignalClient { const result = withAbort(withTimeout(firstMessageOrClose, 5_000), abortSignal).orTee( (error) => { - if (error instanceof AbortError) { + if (error.reason === ConnectionErrorReason.Cancelled) { self .sendLeave() .then(() => self.close()) @@ -863,12 +858,7 @@ export class SignalClient { const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); if (!firstMessage.value) { - return err( - new ConnectionError( - 'no message received as first message', - ConnectionErrorReason.InternalError, - ), - ); + return err(ConnectionError.internal('no message received as first message')); } const firstSignalResponse = parseSignalResponse(firstMessage.value); @@ -962,10 +952,8 @@ export class SignalClient { ); console.trace('[SignalClient.validateFirstMessage] Leave request stack trace'); return err( - new ConnectionError( + ConnectionError.leaveRequest( 'Received leave request while trying to (re)connect', - ConnectionErrorReason.LeaveRequest, - undefined, firstSignalResponse.message.value.reason, ), ); @@ -977,18 +965,15 @@ export class SignalClient { ); console.trace('[SignalClient.validateFirstMessage] Unexpected message trace'); return err( - new ConnectionError( + ConnectionError.internal( `did not receive join response, got ${firstSignalResponse.message?.case} instead`, - ConnectionErrorReason.InternalError, ), ); } console.error('[SignalClient.validateFirstMessage] Unexpected first message'); console.trace('[SignalClient.validateFirstMessage] Unexpected message trace'); - return err( - new ConnectionError('Unexpected first message', ConnectionErrorReason.InternalError), - ); + return err(ConnectionError.internal('Unexpected first message')); } /** @@ -1013,17 +998,16 @@ export class SignalClient { if (resp.status.toFixed(0).startsWith('4')) { const msg = await resp.text(); console.error('[SignalClient.fetchErrorInfo] 4xx error:', msg); - return err(new ConnectionError(msg, ConnectionErrorReason.NotAllowed, resp.status)); + return err(ConnectionError.notAllowed(msg, resp.status)); } else if (reason instanceof ConnectionError) { console.error('[SignalClient.fetchErrorInfo] ConnectionError:', reason); return err(reason); } else { console.error('[SignalClient.fetchErrorInfo] Unknown WebSocket error:', reason); return err( - new ConnectionError( + ConnectionError.internal( `Encountered unknown websocket error during connection: ${reason}`, - ConnectionErrorReason.InternalError, - resp.status, + `ResponseStatus: ${resp.status}`, ), ); } @@ -1033,9 +1017,8 @@ export class SignalClient { return err( e instanceof ConnectionError ? e - : new ConnectionError( + : ConnectionError.serverUnreachable( e instanceof Error ? e.message : 'server was not reachable', - ConnectionErrorReason.ServerUnreachable, ), ); } diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index 2f3ff057ec..33affa9cec 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -1,6 +1,6 @@ // https://github.com/CarterLi/websocketstream-polyfill import { ResultAsync } from 'neverthrow'; -import { AbortError, LivekitError } from '../room/errors'; +import { ConnectionError } from '../room/errors'; import { sleep } from '../room/utils'; export interface WebSocketConnection { @@ -15,20 +15,13 @@ export interface WebSocketCloseInfo { reason?: string; } -// TODO: have more specific websocket errors and make this error type more useful in general -export class WebSocketError extends LivekitError { - readonly type = 'websocket'; - - constructor(message: string) { - super(19, message); - } -} - export interface WebSocketStreamOptions { protocols?: string[]; signal?: AbortSignal; } +export type WebSocketError = ReturnType; + /** * [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) with [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) * @@ -51,7 +44,7 @@ export class WebSocketStream r(err); const errorHandler = (e: Event) => { console.error(e); - reject(new WebSocketError('Encountered websocket error while establishing connection')); + reject( + ConnectionError.websocket('Encountered websocket error while establishing connection'), + ); ws.removeEventListener('open', openHandler); }; const onCloseDuringOpen = (ev: CloseEvent) => { - reject(new WebSocketError(`WS closed during connection establishment: ${ev.reason}`)); + reject( + ConnectionError.websocket( + `WS closed during connection establishment: ${ev.reason}`, + ev.code, + ev.reason, + ), + ); }; const openHandler = () => { @@ -130,7 +131,7 @@ export class WebSocketStream( ra: ResultAsyncLike, ms: number, -): ResultAsync { +): ResultAsync> { const timeout = ResultAsync.fromPromise( new Promise((_, reject) => setTimeout(() => { - reject(new TimeoutError()); + reject(ConnectionError.timeout('Timeout')); }, ms), ), - (e) => e as TimeoutError, + (e) => e as ReturnType, ); return raceResults([ra, timeout]); @@ -72,20 +72,20 @@ export function withTimeout( export function withAbort( ra: ResultAsyncLike, signal: AbortSignal | undefined, -): ResultAsync { +): ResultAsync> { if (signal?.aborted) { - return errAsync(new AbortError()); + return errAsync(ConnectionError.cancelled('AbortSignal invoked')); } const abortResult = ResultAsync.fromPromise( new Promise((_, reject) => { const onAbortHandler = () => { signal?.removeEventListener('abort', onAbortHandler); - reject(new AbortError()); + reject(ConnectionError.cancelled('AbortSignal invoked')); }; signal?.addEventListener('abort', onAbortHandler); }), - (e) => e as AbortError, + (e) => e as ReturnType, ); return raceResults([ra, abortResult]); diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index e805de8ed2..e4d7f6709f 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -3,7 +3,7 @@ import { SignalTarget } from '@livekit/protocol'; import log, { LoggerNames, getLogger } from '../logger'; import PCTransport, { PCEvents } from './PCTransport'; import { roomConnectOptionDefaults } from './defaults'; -import { ConnectionError, ConnectionErrorReason } from './errors'; +import { ConnectionError } from './errors'; import CriticalTimers from './timers'; import type { LoggerOptions } from './types'; import { sleep } from './utils'; @@ -345,12 +345,7 @@ export class PCTransportManager { this.log.warn('abort transport connection', this.logContext); CriticalTimers.clearTimeout(connectTimeout); - reject( - new ConnectionError( - 'room connection has been cancelled', - ConnectionErrorReason.Cancelled, - ), - ); + reject(ConnectionError.cancelled('room connection has been cancelled')); }; if (abortController?.signal.aborted) { abortHandler(); @@ -359,23 +354,13 @@ export class PCTransportManager { const connectTimeout = CriticalTimers.setTimeout(() => { abortController?.signal.removeEventListener('abort', abortHandler); - reject( - new ConnectionError( - 'could not establish pc connection', - ConnectionErrorReason.InternalError, - ), - ); + reject(ConnectionError.internal('could not establish pc connection')); }, timeout); while (this.state !== PCTransportState.CONNECTED) { await sleep(50); // FIXME we shouldn't rely on `sleep` in the connection paths, as it invokes `setTimeout` which can be drastically throttled by browser implementations if (abortController?.signal.aborted) { - reject( - new ConnectionError( - 'room connection has been cancelled', - ConnectionErrorReason.Cancelled, - ), - ); + reject(ConnectionError.cancelled('room connection has been cancelled')); return; } } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index ee0e70a7fb..97f59c34f1 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -363,10 +363,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const publicationTimeout = setTimeout(() => { delete this.pendingTrackResolvers[req.cid]; reject( - new ConnectionError( - 'publication of local track timed out, no response from server', - ConnectionErrorReason.Timeout, - ), + ConnectionError.timeout('publication of local track timed out, no response from server'), ); }, 10_000); this.pendingTrackResolvers[req.cid] = { @@ -1192,10 +1189,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } catch (e: any) { // TODO do we need a `failed` state here for the PC? this.pcState = PCState.Disconnected; - throw new ConnectionError( - `could not establish PC connection, ${e.message}`, - ConnectionErrorReason.InternalError, - ); + throw ConnectionError.internal(`could not establish PC connection, ${e.message}`); } } @@ -1359,10 +1353,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const transport = subscriber ? this.pcManager.subscriber : this.pcManager.publisher; const transportName = subscriber ? 'Subscriber' : 'Publisher'; if (!transport) { - throw new ConnectionError( - `${transportName} connection not set`, - ConnectionErrorReason.InternalError, - ); + throw ConnectionError.internal(`${transportName} connection not set`); } let needNegotiation = false; @@ -1403,9 +1394,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit await sleep(50); } - throw new ConnectionError( + throw ConnectionError.internal( `could not establish ${transportName} connection, state: ${transport.getICEConnectionState()}`, - ConnectionErrorReason.InternalError, ); } diff --git a/src/room/RegionUrlProvider.ts b/src/room/RegionUrlProvider.ts index 330f985ed6..d602a92ba3 100644 --- a/src/room/RegionUrlProvider.ts +++ b/src/room/RegionUrlProvider.ts @@ -1,7 +1,7 @@ import { Mutex } from '@livekit/mutex'; import type { RegionInfo, RegionSettings } from '@livekit/protocol'; import log from '../logger'; -import { ConnectionError, ConnectionErrorReason } from './errors'; +import { ConnectionError } from './errors'; import { extractMaxAgeFromRequestHeaders, isCloud } from './utils'; export const DEFAULT_MAX_AGE_MS = 5_000; @@ -37,25 +37,26 @@ export class RegionUrlProvider { const regionSettings = (await regionSettingsResponse.json()) as RegionSettings; return { regionSettings, updatedAtInMs: Date.now(), maxAgeInMs }; } else { - throw new ConnectionError( - `Could not fetch region settings: ${regionSettingsResponse.statusText}`, - regionSettingsResponse.status === 401 - ? ConnectionErrorReason.NotAllowed - : ConnectionErrorReason.InternalError, - regionSettingsResponse.status, - ); + throw regionSettingsResponse.status === 401 + ? ConnectionError.notAllowed( + `Could not fetch region settings: ${regionSettingsResponse.statusText}`, + regionSettingsResponse.status, + ) + : ConnectionError.internal( + `Could not fetch region settings: ${regionSettingsResponse.statusText}`, + { status: regionSettingsResponse.status }, + ); } } catch (e: unknown) { if (e instanceof ConnectionError) { // rethrow connection errors throw e; } else if (signal?.aborted) { - throw new ConnectionError(`Region fetching was aborted`, ConnectionErrorReason.Cancelled); + throw ConnectionError.cancelled(`Region fetching was aborted`); } else { // wrap other errors as connection errors (e.g. timeouts) - throw new ConnectionError( + throw ConnectionError.serverUnreachable( `Could not fetch region settings, ${e instanceof Error ? `${e.name}: ${e.message}` : e}`, - ConnectionErrorReason.ServerUnreachable, 500, // using 500 as a catch-all manually set error code here ); } diff --git a/src/room/Room.ts b/src/room/Room.ts index 7b6e251e72..3ab34b5975 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -686,7 +686,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) try { await BackOffStrategy.getInstance().getBackOffPromise(url); if (abortController.signal.aborted) { - throw new ConnectionError('Connection attempt aborted', ConnectionErrorReason.Cancelled); + ConnectionError.cancelled('Connection attempt aborted'); } await this.attemptConnection(regionUrl ?? url, token, opts, abortController); this.abortController = undefined; @@ -901,12 +901,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) } catch (err) { await this.engine.close(); this.recreateEngine(); - const resultingError = new ConnectionError( - `could not establish signal connection`, - abortController.signal.aborted - ? ConnectionErrorReason.Cancelled - : ConnectionErrorReason.ServerUnreachable, - ); + const resultingError = abortController.signal.aborted + ? ConnectionError.cancelled(`could not establish signal connection`) + : ConnectionError.serverUnreachable(`could not establish signal connection`); if (err instanceof Error) { resultingError.message = `${resultingError.message}: ${err.message}`; } @@ -924,7 +921,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (abortController.signal.aborted) { await this.engine.close(); this.recreateEngine(); - throw new ConnectionError(`Connection attempt aborted`, ConnectionErrorReason.Cancelled); + throw ConnectionError.cancelled(`Connection attempt aborted`); } try { @@ -976,9 +973,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.log.warn(msg, this.logContext); this.abortController?.abort(msg); // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly - this.connectFuture?.reject?.( - new ConnectionError('Client initiated disconnect', ConnectionErrorReason.Cancelled), - ); + this.connectFuture?.reject?.(ConnectionError.cancelled('Client initiated disconnect')); this.connectFuture = undefined; } diff --git a/src/room/errors.ts b/src/room/errors.ts index 9b8a33a466..b77da726c6 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -1,7 +1,5 @@ import { DisconnectReason, RequestResponse_Reason } from '@livekit/protocol'; -// TODO properly define necessary Errors (make them backwards compatible) - export class LivekitError extends Error { code: number; @@ -16,7 +14,7 @@ export class SimulatedError extends LivekitError { readonly type = 'simulated'; constructor(message = 'Simulated failure') { - super(0, message); + super(-1, message); } } @@ -27,30 +25,133 @@ export enum ConnectionErrorReason { Cancelled, LeaveRequest, Timeout, + WebSocket, } -export class ConnectionError extends LivekitError { +type NotAllowed = { + reason: ConnectionErrorReason.NotAllowed; + status: number; + context?: unknown; +}; + +type InternalError = { + reason: ConnectionErrorReason.InternalError; + status: never; + context?: unknown; +}; + +type ConnectionTimeout = { + reason: ConnectionErrorReason.Timeout; + status: never; + context: never; +}; + +type LeaveRequest = { + reason: ConnectionErrorReason.LeaveRequest; + status: never; + context: DisconnectReason; +}; + +type Cancelled = { + reason: ConnectionErrorReason.Cancelled; + status: never; + context: never; +}; + +type ServerUnreachable = { + reason: ConnectionErrorReason.ServerUnreachable; + status?: number; + context?: unknown; +}; + +type WebSocket = { + reason: ConnectionErrorReason.WebSocket; status?: number; + context?: string; +}; - context?: unknown | DisconnectReason; +type ConnectionErrorVariants = + | NotAllowed + | ConnectionTimeout + | LeaveRequest + | InternalError + | Cancelled + | ServerUnreachable + | WebSocket; - reason: ConnectionErrorReason; +export class ConnectionError< + Variant extends ConnectionErrorVariants = ConnectionErrorVariants, +> extends LivekitError { + status?: Variant['status']; + + context: Variant['context']; + + reason: Variant['reason']; reasonName: string; - constructor( + readonly name = 'ConnectionError'; + + protected constructor( message: string, - reason: ConnectionErrorReason, - status?: number, - context?: unknown | DisconnectReason, + reason: Variant['reason'], + status?: Variant['status'], + context?: Variant['context'], ) { super(1, message); - this.name = 'ConnectionError'; this.status = status; this.reason = reason; this.context = context; this.reasonName = ConnectionErrorReason[reason]; } + + static notAllowed(message: string, status: number, context?: unknown) { + return new ConnectionError( + message, + ConnectionErrorReason.NotAllowed, + status, + context, + ); + } + + static timeout(message: string) { + return new ConnectionError(message, ConnectionErrorReason.Timeout); + } + + static leaveRequest(message: string, context: DisconnectReason) { + return new ConnectionError( + message, + ConnectionErrorReason.LeaveRequest, + undefined, + context, + ); + } + + static internal(message: string, context?: unknown) { + return new ConnectionError( + message, + ConnectionErrorReason.InternalError, + undefined, + context, + ); + } + + static cancelled(message: string) { + return new ConnectionError(message, ConnectionErrorReason.Cancelled); + } + + static serverUnreachable(message: string, status?: number, context?: unknown) { + return new ConnectionError( + message, + ConnectionErrorReason.ServerUnreachable, + status, + context, + ); + } + + static websocket(message: string, status?: number, reason?: string) { + return new ConnectionError(message, ConnectionErrorReason.WebSocket, status, reason); + } } export class DeviceUnsupportedError extends LivekitError { @@ -121,23 +222,23 @@ export class SignalRequestError extends LivekitError { } } -export class TimeoutError extends LivekitError { - readonly type = 'timeout'; +// export class TimeoutError extends LivekitError { +// readonly type = 'timeout'; - constructor(message: string = 'TimeoutError') { - super(17, message); - this.name = 'TimeoutError'; - } -} +// constructor(message: string = 'TimeoutError') { +// super(17, message); +// this.name = 'TimeoutError'; +// } +// } -export class AbortError extends LivekitError { - readonly type = 'abort'; +// export class AbortError extends LivekitError { +// readonly type = 'abort'; - constructor(message: string = 'AbortError') { - super(18, message); - this.name = 'AbortError'; - } -} +// constructor(message: string = 'AbortError') { +// super(18, message); +// this.name = 'AbortError'; +// } +// } // NOTE: matches with https://github.com/livekit/client-sdk-swift/blob/f37bbd260d61e165084962db822c79f995f1a113/Sources/LiveKit/DataStream/StreamError.swift#L17 export enum DataStreamErrorReason { From 71c36a4f00a58a41d011801fad6040ae26115f88 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 14 Nov 2025 12:45:43 +0100 Subject: [PATCH 22/58] remove debug logs --- src/api/SignalClient.ts | 58 ++--------------------------------------- src/e2ee/E2eeManager.ts | 2 +- 2 files changed, 3 insertions(+), 57 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index c1b4b09236..9aa94e4022 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -273,7 +273,6 @@ export class SignalClient { U extends T extends false ? JoinResponse : ReconnectResponse | undefined, >(url: string, token: string, isReconnect: T, opts: ConnectOpts, abortSignal?: AbortSignal) { const self = this; - const connectLabel = `SignalClient.connect [${isReconnect ? 'reconnect' : 'join'}]`; return withMutex( safeTry(async function* () { @@ -305,9 +304,6 @@ export class SignalClient { const wsConnectionResult = withTimeout(self.ws.opened, opts.websocketTimeout).mapErr( async (error) => { - console.error(`[${connectLabel}] WebSocket connection failed:`, error); - console.trace(`[${connectLabel}] WebSocket error stack trace`); - // retrieve info about what error was causing the connection failure and enhance the returned error if (self.state !== SignalConnectionState.CONNECTED) { self.state = SignalConnectionState.DISCONNECTED; @@ -320,10 +316,6 @@ export class SignalClient { self.close(undefined, closeReason); if (connectionError.isErr()) { - console.error( - `[${connectLabel}] Connection error after fetch:`, - connectionError.error, - ); return connectionError.error; } } @@ -340,12 +332,6 @@ export class SignalClient { // Return the close promise as error if it resolves first ws!.closed .andThen((closeInfo) => { - console.warn(`[${connectLabel}] WebSocket closed during connection:`, { - reason: closeInfo.reason, - code: closeInfo.closeCode, - wasClean: closeInfo.closeCode === 1000, - }); - if ( closeInfo.closeCode !== 1000 && // we only log the warning here if the current ws connection is still the same, we don't care about closing of older ws connections that have been replaced @@ -367,8 +353,6 @@ export class SignalClient { ); }) .orTee((error) => { - console.error(`[${connectLabel}] WebSocket error during connection:`, error); - console.trace(`[${connectLabel}] WebSocket error trace`); self.handleWSError(error); }), ]); @@ -849,9 +833,8 @@ export class SignalClient { U extends T extends false ? JoinResponse : ReconnectResponse | undefined, >(connection: WebSocketConnection, isReconnect: T): ResultAsync { const self = this; - const processLabel = `SignalClient.processInitialSignalMessage [${isReconnect ? 'reconnect' : 'join'}]`; - // TODO: This should be more granular here than ConnectionError + // TODO: If inferring from the return type this could be more granular here than ConnectionError return safeTry(async function* () { const signalReader = connection.readable.getReader(); self.streamWriter = connection.writable.getWriter(); @@ -871,10 +854,6 @@ export class SignalClient { self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { - console.log(`[${processLabel}] Ping config:`, { - timeout: self.pingTimeoutDuration, - interval: self.pingIntervalDuration, - }); self.log.debug('ping config', { ...self.logContext, timeout: self.pingTimeoutDuration, @@ -908,14 +887,7 @@ export class SignalClient { // TODO, this should probably not be a ConnectionError? ConnectionError > { - console.log('[SignalClient.validateFirstMessage]', { - messageCase: firstSignalResponse.message?.case, - isReconnect, - state: this.state, - }); - if (isReconnect === false && firstSignalResponse.message?.case === 'join') { - console.log('[SignalClient.validateFirstMessage] Valid join response'); return ok({ response: firstSignalResponse.message.value, shouldProcessFirstMessage: false, @@ -926,14 +898,12 @@ export class SignalClient { firstSignalResponse.message?.case !== 'leave' ) { if (firstSignalResponse.message?.case === 'reconnect') { - console.log('[SignalClient.validateFirstMessage] Valid reconnect response'); return ok({ response: firstSignalResponse.message.value, shouldProcessFirstMessage: false, }); } else { // in reconnecting, any message received means signal reconnected and we still need to process it - console.log('[SignalClient.validateFirstMessage] Reconnected without reconnect response'); this.log.debug( 'declaring signal reconnected without reconnect response received', this.logContext, @@ -944,13 +914,6 @@ export class SignalClient { }); } } else if (this.isEstablishingConnection && firstSignalResponse.message?.case === 'leave') { - console.error( - '[SignalClient.validateFirstMessage] Received leave request during connection', - { - leaveReason: firstSignalResponse.message.value.reason, - }, - ); - console.trace('[SignalClient.validateFirstMessage] Leave request stack trace'); return err( ConnectionError.leaveRequest( 'Received leave request while trying to (re)connect', @@ -959,20 +922,13 @@ export class SignalClient { ); } else if (!isReconnect) { // non-reconnect case, should receive join response first - console.error( - '[SignalClient.validateFirstMessage] Expected join, got:', - firstSignalResponse.message?.case, - ); - console.trace('[SignalClient.validateFirstMessage] Unexpected message trace'); + return err( ConnectionError.internal( `did not receive join response, got ${firstSignalResponse.message?.case} instead`, ), ); } - - console.error('[SignalClient.validateFirstMessage] Unexpected first message'); - console.trace('[SignalClient.validateFirstMessage] Unexpected message trace'); return err(ConnectionError.internal('Unexpected first message')); } @@ -987,23 +943,15 @@ export class SignalClient { reason: unknown, validateUrl: string, ): Promise> { - console.log('[SignalClient.fetchErrorInfo] Fetching error info:', { reason, validateUrl }); try { const resp = await fetch(validateUrl); - console.log('[SignalClient.fetchErrorInfo] Validation response:', { - status: resp.status, - statusText: resp.statusText, - }); if (resp.status.toFixed(0).startsWith('4')) { const msg = await resp.text(); - console.error('[SignalClient.fetchErrorInfo] 4xx error:', msg); return err(ConnectionError.notAllowed(msg, resp.status)); } else if (reason instanceof ConnectionError) { - console.error('[SignalClient.fetchErrorInfo] ConnectionError:', reason); return err(reason); } else { - console.error('[SignalClient.fetchErrorInfo] Unknown WebSocket error:', reason); return err( ConnectionError.internal( `Encountered unknown websocket error during connection: ${reason}`, @@ -1012,8 +960,6 @@ export class SignalClient { ); } } catch (e) { - console.error('[SignalClient.fetchErrorInfo] Fetch failed:', e); - console.trace('[SignalClient.fetchErrorInfo] Fetch error trace'); return err( e instanceof ConnectionError ? e diff --git a/src/e2ee/E2eeManager.ts b/src/e2ee/E2eeManager.ts index 9069261595..23b2f3a339 100644 --- a/src/e2ee/E2eeManager.ts +++ b/src/e2ee/E2eeManager.ts @@ -225,7 +225,7 @@ export class E2EEManager }; private onWorkerError = (ev: ErrorEvent) => { - log.error('e2ee worker encountered an error:', { error: ev.error }); + log.error('e2ee worker encountered an error:', { error: ev }); this.emit(EncryptionEvent.EncryptionError, ev.error, undefined); }; From 44418bee0ed536dd23eeb4ceec567ee4a4ed1b19 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 14 Nov 2025 12:58:09 +0100 Subject: [PATCH 23/58] only log unexpected ws errors --- src/api/SignalClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 9aa94e4022..4efa0bd949 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -331,6 +331,9 @@ export class SignalClient { self.processInitialSignalMessage(wsConnection, isReconnect), // Return the close promise as error if it resolves first ws!.closed + .orTee((error) => { + self.handleWSError(error); + }) .andThen((closeInfo) => { if ( closeInfo.closeCode !== 1000 && @@ -351,9 +354,6 @@ export class SignalClient { closeInfo.reason ?? 'Websocket closed during (re)connection attempt', ), ); - }) - .orTee((error) => { - self.handleWSError(error); }), ]); @@ -760,7 +760,7 @@ export class SignalClient { } } - private handleWSError(error: unknown) { + private handleWSError(error: ReturnType) { this.log.error('websocket error', { ...this.logContext, error }); } From 55cad08a5c2a3887325714a997ce46748485f1e1 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 14 Nov 2025 15:19:15 +0100 Subject: [PATCH 24/58] Ensure unexpected WS closure triggers reconnect --- src/api/SignalClient.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 4efa0bd949..a9f0c7981d 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -347,6 +347,9 @@ export class SignalClient { wasClean: closeInfo.closeCode === 1000, state: self.state, }); + if (self.state == SignalConnectionState.CONNECTED) { + self.handleOnClose(closeInfo.reason ?? 'Websocket closed unexpectedly'); + } } return err( @@ -750,11 +753,11 @@ export class SignalClient { } } - private async handleOnClose(reason: string) { + private handleOnClose(reason: string) { if (this.state === SignalConnectionState.DISCONNECTED) return; const onCloseCallback = this.onClose; - await this.close(undefined, reason); - this.log.debug(`websocket connection closed: ${reason}`, { ...this.logContext, reason }); + this.close(undefined, reason); + this.log.debug(`websocket connection closing: ${reason}`, { ...this.logContext, reason }); if (onCloseCallback) { onCloseCallback(reason); } From 8a2a9e706ced844b239bf8cf14f286e23719b75a Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 14 Nov 2025 15:21:25 +0100 Subject: [PATCH 25/58] use newer gh actions like before --- .github/workflows/test.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4432c2f89a..891e2ef335 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,12 +9,12 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - name: Use Node.js 20 - uses: actions/setup-node@v4 + - name: Use Node.js 24 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: 'pnpm' - name: Install dependencies From 8daecaece0547e7768d81e4acfb4ab1338d8d8f4 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 14 Nov 2025 15:26:42 +0100 Subject: [PATCH 26/58] return early if disconnected --- src/api/SignalClient.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index a9f0c7981d..c5ef10ef30 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -417,6 +417,13 @@ export class SignalClient { }; async close(updateState: boolean = true, reason = 'Close method called on signal client') { + if ( + this.state === SignalConnectionState.DISCONNECTING || + this.state === SignalConnectionState.DISCONNECTED + ) { + this.log.info(`Skipping signal client close, already disconnecting`); + return; + } const unlock = await this.closingLock.lock(); try { this.clearPingInterval(); From dd7d850d53b9e2aacc928c36366106bb2aa3f022 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 09:18:57 +0100 Subject: [PATCH 27/58] Fix tests --- src/api/SignalClient.test.ts | 4 ++-- src/api/WebSocketStream.test.ts | 7 ++++--- src/room/RegionUrlProvider.test.ts | 12 +++++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index d5afc42d3c..97205df97f 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -236,7 +236,7 @@ describe('SignalClient.connect', () => { ); expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().message).toBe('AbortError'); + expect(result._unsafeUnwrapErr().message).toBe('AbortSignal invoked'); }); it('should send leave request before closing when AbortSignal is triggered during connection', async () => { @@ -315,7 +315,7 @@ describe('SignalClient.connect', () => { // joinPromise should return Err result const result = await joinPromise; expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().message).toBe('AbortError'); + expect(result._unsafeUnwrapErr().message).toBe('AbortSignal invoked'); // Verify that a leave request was sent before closing const leaveRequestSent = writtenMessages.some((data) => { diff --git a/src/api/WebSocketStream.test.ts b/src/api/WebSocketStream.test.ts index 6228fc3240..32d1a5d5ad 100644 --- a/src/api/WebSocketStream.test.ts +++ b/src/api/WebSocketStream.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConnectionErrorReason } from '../room/errors'; import { WebSocketStream } from './WebSocketStream'; // Mock WebSocket @@ -184,7 +185,7 @@ describe('WebSocketStream', () => { new WebSocketStream('wss://test.example.com', { signal: abortController.signal, }); - }).toThrow('AbortError'); + }).toThrow('Aborted before WS was initialized'); }); it('should close when abort signal is triggered', () => { @@ -232,7 +233,7 @@ describe('WebSocketStream', () => { const result = await wsStream.opened; expect(result.isErr()).toBe(true); if (result.isErr()) { - expect(result.error.type).toBe('websocket'); + expect(result.error.reason).toBe(ConnectionErrorReason.WebSocket); } }); }); @@ -283,7 +284,7 @@ describe('WebSocketStream', () => { const result = await wsStream.closed; expect(result.isErr()).toBe(true); if (result.isErr()) { - expect(result.error.type).toBe('websocket'); + expect(result.error.reason).toBe(ConnectionErrorReason.WebSocket); expect(result.error.message).toBe( 'Encountered unspecified websocket error without a timely close event', ); diff --git a/src/room/RegionUrlProvider.test.ts b/src/room/RegionUrlProvider.test.ts index 6794e76069..712c82d036 100644 --- a/src/room/RegionUrlProvider.test.ts +++ b/src/room/RegionUrlProvider.test.ts @@ -180,7 +180,9 @@ describe('RegionUrlProvider', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(401)); - await expect(provider.fetchRegionSettings()).rejects.toThrow(ConnectionError); + await expect(provider.fetchRegionSettings()).rejects.toThrow( + ConnectionError.notAllowed('Could not fetch region settings: Unauthorized', 401), + ); await expect(provider.fetchRegionSettings()).rejects.toMatchObject({ reason: ConnectionErrorReason.NotAllowed, status: 401, @@ -191,10 +193,14 @@ describe('RegionUrlProvider', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(500)); - await expect(provider.fetchRegionSettings()).rejects.toThrow(ConnectionError); + await expect(provider.fetchRegionSettings()).rejects.toThrow( + ConnectionError.internal('Could not fetch region settings: Internal Server Error', { + status: 500, + }), + ); await expect(provider.fetchRegionSettings()).rejects.toMatchObject({ reason: ConnectionErrorReason.InternalError, - status: 500, + context: { status: 500 }, }); }); From 0df13d29c575e77b3ac59aefb1f6de219960fb71 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 09:32:19 +0100 Subject: [PATCH 28/58] prep connection --- examples/demo/demo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 7232998ee5..c0dbd095a5 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -166,7 +166,7 @@ const appActions = { const room = new Room(roomOptions); startTime = Date.now(); - // await room.prepareConnection(url, token); + await room.prepareConnection(url, token); const prewarmTime = Date.now() - startTime; appendLog(`prewarmed connection in ${prewarmTime}ms`); room.localParticipant.on(ParticipantEvent.LocalTrackCpuConstrained, (track, publication) => { From 0a03da6be7a31af759530e219ce650a8aacab14c Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 09:34:29 +0100 Subject: [PATCH 29/58] Change livekit-client version from patch to minor Updated version type for livekit-client from patch to minor. --- .changeset/early-numbers-build.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/early-numbers-build.md diff --git a/.changeset/early-numbers-build.md b/.changeset/early-numbers-build.md new file mode 100644 index 0000000000..973700561a --- /dev/null +++ b/.changeset/early-numbers-build.md @@ -0,0 +1,5 @@ +--- +"livekit-client": minor +--- + +Typesafe error propagation in signal connection path From 4d25b5460bb8d2422bea7eef2fd3c940234bfee4 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 12:39:58 +0100 Subject: [PATCH 30/58] wip --- src/api/utils.ts | 14 ++ src/room/RTCEngine.ts | 164 +++++++++--------- src/room/participant/LocalParticipant.ts | 204 ++++++++++++----------- src/room/track/LocalTrack.ts | 9 +- 4 files changed, 215 insertions(+), 176 deletions(-) diff --git a/src/api/utils.ts b/src/api/utils.ts index d5766d522b..e25b596ac6 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,6 +1,8 @@ import type { Mutex } from '@livekit/mutex'; import { SignalResponse } from '@livekit/protocol'; import { Result, ResultAsync, errAsync } from 'neverthrow'; +import type TypedEventEmitter from 'typed-emitter'; +import type { EventMap } from 'typed-emitter'; import { ConnectionError } from '../room/errors'; import { toHttpUrl, toWebsocketUrl } from '../room/utils'; @@ -179,3 +181,15 @@ export function raceResults[]>( } export type ResultAsyncLike = ResultAsync | Promise>; + +export function resultFromEvent( + emitter: TypedEventEmitter, + event: K, +): ResultAsync, never> { + const resultPromise = new Promise>((resolve) => { + emitter.once(event, ((...args: Parameters) => { + resolve(args); + }) as C[K]); + }); + return ResultAsync.fromSafePromise(resultPromise); +} diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 6365c4d096..863586dd94 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -39,7 +39,7 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; -import { type Result, err, ok } from 'neverthrow'; +import { type Result, ResultAsync, err, errAsync, ok, okAsync } from 'neverthrow'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; @@ -48,6 +48,7 @@ import { SignalConnectionState, toProtoSessionDescription, } from '../api/SignalClient'; +import { raceResults, resultFromEvent, withFinally, withTimeout } from '../api/utils'; import type { BaseE2EEManager } from '../e2ee/E2eeManager'; import { asEncryptablePacket } from '../e2ee/utils'; import log, { LoggerNames, getLogger } from '../logger'; @@ -295,7 +296,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // create offer if (!this.subscriberPrimary || joinResponse.fastPublish) { - this.negotiate().catch((error) => { + this.negotiate().orTee((error) => { log.error(error, this.logContext); }); } @@ -359,29 +360,32 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.client.resetCallbacks(); } - addTrack(req: AddTrackRequest): Promise { + addTrack(req: AddTrackRequest): ResultAsync { if (this.pendingTrackResolvers[req.cid]) { - throw new TrackInvalidError('a track with the same ID has already been published'); - } - return new Promise((resolve, reject) => { - const publicationTimeout = setTimeout(() => { - delete this.pendingTrackResolvers[req.cid]; - reject( - ConnectionError.timeout('publication of local track timed out, no response from server'), - ); - }, 10_000); - this.pendingTrackResolvers[req.cid] = { - resolve: (info: TrackInfo) => { - clearTimeout(publicationTimeout); - resolve(info); - }, - reject: () => { - clearTimeout(publicationTimeout); - reject(new Error('Cancelled publication by calling unpublish')); - }, - }; - this.client.sendAddTrack(req); + return errAsync(new TrackInvalidError('a track with the same ID has already been published')); + } + + const pendingPromiseResult = ResultAsync.fromPromise( + new Promise((resolve, rej) => { + const reject = (error: ReturnType) => rej(error); + this.pendingTrackResolvers[req.cid] = { + resolve: (info: TrackInfo) => { + resolve(info); + }, + // TODO ensure no other parts of the SDK reject this promise with a different error + reject: () => { + reject(ConnectionError.cancelled('Cancelled publication by calling unpublish')); + }, + }; + }), + (e) => e as ReturnType, + ); + + const addTrackResult = withFinally(withTimeout(pendingPromiseResult, 10_000), () => { + delete this.pendingTrackResolvers[req.cid]; }); + + return addTrackResult; } /** @@ -396,7 +400,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (reject) { reject(); } - delete this.pendingTrackResolvers[sender.track.id]; } try { this.pcManager!.removeTrack(sender); @@ -559,7 +562,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return; } const { resolve } = this.pendingTrackResolvers[res.cid]; - delete this.pendingTrackResolvers[res.cid]; resolve(res.track!); }; @@ -1376,7 +1378,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } if (needNegotiation) { // start negotiation - this.negotiate().catch((error) => { + this.negotiate().orTee((error) => { log.error(error, this.logContext); }); } @@ -1428,64 +1430,72 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } /** @internal */ - async negotiate(): Promise { + negotiate(): ResultAsync { + if (!this.pcManager) { + return errAsync(new NegotiationError('PC manager is closed')); + } // observe signal state - return new Promise(async (resolve, reject) => { - if (!this.pcManager) { - reject(new NegotiationError('PC manager is closed')); - return; - } - this.pcManager.requirePublisher(); - // don't negotiate without any transceivers or data channel, it will generate sdp without ice frag then negotiate failed - if ( - this.pcManager.publisher.getTransceivers().length == 0 && - !this.lossyDC && - !this.reliableDC - ) { - this.createDataChannels(); - } + this.pcManager.requirePublisher(); + // don't negotiate without any transceivers or data channel, it will generate sdp without ice frag then negotiate failed + if ( + this.pcManager.publisher.getTransceivers().length == 0 && + !this.lossyDC && + !this.reliableDC + ) { + this.createDataChannels(); + } - const abortController = new AbortController(); + const abortController = new AbortController(); - const handleClosed = () => { - abortController.abort(); - this.log.debug('engine disconnected while negotiation was ongoing', this.logContext); - resolve(); - return; - }; + const handleClosed = () => { + abortController.abort(); + this.log.debug('engine disconnected while negotiation was ongoing', this.logContext); + okAsync(); + return; + }; - if (this.isClosed) { - reject('cannot negotiate on closed engine'); - } - this.on(EngineEvent.Closing, handleClosed); - - this.pcManager.publisher.once( - PCEvents.RTPVideoPayloadTypes, - (rtpTypes: MediaAttributes['rtp']) => { - const rtpMap = new Map(); - rtpTypes.forEach((rtp) => { - const codec = rtp.codec.toLowerCase(); - if (isVideoCodec(codec)) { - rtpMap.set(rtp.payload, codec); - } - }); - this.emit(EngineEvent.RTPVideoMapUpdate, rtpMap); - }, - ); + if (this.isClosed) { + return errAsync(new NegotiationError('cannot negotiate on closed engine')); + } - try { - await this.pcManager.negotiate(abortController); - resolve(); - } catch (e: any) { - if (e instanceof NegotiationError) { - this.fullReconnectOnNext = true; - } - this.handleDisconnect('negotiation', ReconnectReason.RR_UNKNOWN); - reject(e); - } finally { - this.off(EngineEvent.Closing, handleClosed); + this.pcManager.publisher.once( + PCEvents.RTPVideoPayloadTypes, + (rtpTypes: MediaAttributes['rtp']) => { + const rtpMap = new Map(); + rtpTypes.forEach((rtp) => { + const codec = rtp.codec.toLowerCase(); + if (isVideoCodec(codec)) { + rtpMap.set(rtp.payload, codec); + } + }); + this.emit(EngineEvent.RTPVideoMapUpdate, rtpMap); + }, + ); + + const closingResult = resultFromEvent( + this, + EngineEvent.Closing, + ).andThen(() => err(new NegotiationError('engine disconnected while negotiation was ongoing'))); + + const closedOrNegotiate = raceResults([ + closingResult, + // TODO ensure pcManager.negotiate can only throw/return Negotiation errors + ResultAsync.fromPromise( + this.pcManager.negotiate(abortController), + (e) => e as NegotiationError, + ), + ]); + + const negotiationWithErrorHandler = closedOrNegotiate.orTee((e) => { + if (e instanceof NegotiationError) { + this.fullReconnectOnNext = true; } + this.handleDisconnect('negotiation', ReconnectReason.RR_UNKNOWN); + }); + + return withFinally(negotiationWithErrorHandler, () => { + this.off(EngineEvent.Closing, handleClosed); }); } diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index eff359451f..2f6af1f3c7 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -30,8 +30,10 @@ import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingData import type { TextStreamWriter } from '../data-stream/outgoing/StreamWriter'; import { defaultVideoCodec } from '../defaults'; import { + ConnectionError, DeviceUnsupportedError, LivekitError, + NegotiationError, PublishTrackError, SignalRequestError, TrackInvalidError, @@ -103,6 +105,7 @@ import { computeVideoEncodings, getDefaultDegradationPreference, } from './publishUtils'; +import { errAsync, ok, ResultAsync, safeTry } from 'neverthrow'; export default class LocalParticipant extends Participant { audioTrackPublications: Map; @@ -954,9 +957,10 @@ export default class LocalParticipant extends Participant { return false; } - private async publish(track: LocalTrack, opts: TrackPublishOptions, isStereo: boolean) { + private publish(track: LocalTrack, opts: TrackPublishOptions, isStereo: boolean): ResultAsync { + if (!this.hasPermissionsToPublish(track)) { - throw new PublishTrackError('failed to publish track, insufficient permissions', 403); + return errAsync(new PublishTrackError('failed to publish track, insufficient permissions', 403)); } const existingTrackOfSource = Array.from(this.trackPublications.values()).find( (publishedTrack) => isLocalTrack(track) && publishedTrack.source === track.source, @@ -1049,31 +1053,33 @@ export default class LocalParticipant extends Participant { audioFeatures, }); + const self = this; + + return safeTry(async function*() { // compute encodings and layers for video let encodings: RTCRtpEncodingParameters[] | undefined; if (track.kind === Track.Kind.Video) { - let dims: Track.Dimensions = { - width: 0, - height: 0, - }; - try { - dims = await track.waitForDimensions(); - } catch (e) { - // use defaults, it's quite painful for congestion control without simulcast - // so using default dims according to publish settings + + const dims = yield* (await track.waitForDimensions()).orElse(() => { const defaultRes = - this.roomOptions.videoCaptureDefaults?.resolution ?? VideoPresets.h720.resolution; - dims = { + self.roomOptions.videoCaptureDefaults?.resolution ?? VideoPresets.h720.resolution; + + // use defaults, it's quite painful for congestion control without simulcast + // so using default dims according to publish settings + const defaultDims = { width: defaultRes.width, height: defaultRes.height, - }; + } satisfies Track.Dimensions; + // log failure - this.log.error('could not determine track dimensions, using defaults', { - ...this.logContext, + self.log.error('could not determine track dimensions, using defaults', { + ...self.logContext, ...getLogContextFromTrack(track), dims, }); - } + return ok(defaultDims as Track.Dimensions); + }) + // width and height should be defined for video req.width = dims.width; req.height = dims.height; @@ -1091,8 +1097,8 @@ export default class LocalParticipant extends Participant { // that we need if ('contentHint' in track.mediaStreamTrack) { track.mediaStreamTrack.contentHint = 'motion'; - this.log.info('forcing contentHint to motion for screenshare with SVC codecs', { - ...this.logContext, + self.log.info('forcing contentHint to motion for screenshare with SVC codecs', { + ...self.logContext, ...getLogContextFromTrack(track), }); } @@ -1119,8 +1125,8 @@ export default class LocalParticipant extends Participant { req.encryption === Encryption_Type.NONE ) { // multi-codec simulcast requires dynacast - if (!this.roomOptions.dynacast) { - this.roomOptions.dynacast = true; + if (!self.roomOptions.dynacast) { + self.roomOptions.dynacast = true; } req.simulcastCodecs.push( new SimulcastCodec({ @@ -1153,17 +1159,17 @@ export default class LocalParticipant extends Participant { ]; } - if (!this.engine || this.engine.isClosed) { + if (!self.engine || self.engine.isClosed) { throw new UnexpectedConnectionState('cannot publish track when not connected'); } - const negotiate = async () => { - if (!this.engine.pcManager) { - throw new UnexpectedConnectionState('pcManager is not ready'); + const negotiate = (): ResultAsync => { + if (!self.engine.pcManager) { + return errAsync(new UnexpectedConnectionState('pcManager is not ready')); } - track.sender = await this.engine.createSender(track, opts, encodings); - this.emit(ParticipantEvent.LocalSenderCreated, track.sender, track); + track.sender = await self.engine.createSender(track, opts, encodings); + self.emit(ParticipantEvent.LocalSenderCreated, track.sender, track); if (isLocalVideoTrack(track)) { opts.degradationPreference ??= getDefaultDegradationPreference(track); @@ -1202,92 +1208,95 @@ export default class LocalParticipant extends Participant { } } - await this.engine.negotiate(); + return this.engine.negotiate(); }; - let ti: TrackInfo; - const addTrackPromise = new Promise(async (resolve, reject) => { - try { - ti = await this.engine.addTrack(req); - resolve(ti); - } catch (err) { - if (track.sender && this.engine.pcManager?.publisher) { - this.engine.pcManager.publisher.removeTrack(track.sender); - await this.engine.negotiate().catch((negotiateErr) => { - this.log.error( + const addTrackResult = self.engine.addTrack(req); + + const addTrackWithFallback = addTrackResult.mapErr((e) => { + if (track.sender && self.engine.pcManager?.publisher) { + self.engine.pcManager.publisher.removeTrack(track.sender); + self.engine.negotiate().orTee((negotiateErr) => { + self.log.error( 'failed to negotiate after removing track due to failed add track request', { - ...this.logContext, + ...self.logContext, ...getLogContextFromTrack(track), error: negotiateErr, }, ); }); } - reject(err); - } + return e; }); - if (this.enabledPublishVideoCodecs.length > 0) { - const rets = await Promise.all([addTrackPromise, negotiate()]); - ti = rets[0]; + + let trackInfoResult: ResultAsync + + if (self.enabledPublishVideoCodecs.length > 0) { + trackInfoResult = ResultAsync.combine([addTrackWithFallback, negotiate()]).map(t => t[0]); } else { - ti = await addTrackPromise; - // server might not support the codec the client has requested, in that case, fallback - // to a supported codec - let primaryCodecMime: string | undefined; - ti.codecs.forEach((codec) => { - if (primaryCodecMime === undefined) { - primaryCodecMime = codec.mimeType; - } - }); - if (primaryCodecMime && track.kind === Track.Kind.Video) { - const updatedCodec = mimeTypeToVideoCodecString(primaryCodecMime); - if (updatedCodec !== videoCodec) { - this.log.debug('falling back to server selected codec', { - ...this.logContext, - ...getLogContextFromTrack(track), - codec: updatedCodec, - }); - opts.videoCodec = updatedCodec; - - // recompute encodings since bitrates/etc could have changed - encodings = computeVideoEncodings( - track.source === Track.Source.ScreenShare, - req.width, - req.height, - opts, - ); + trackInfoResult = addTrackWithFallback.andThen((ti) => { + // server might not support the codec the client has requested, in that case, fallback + // to a supported codec + let primaryCodecMime: string | undefined; + ti.codecs.forEach((codec) => { + if (primaryCodecMime === undefined) { + primaryCodecMime = codec.mimeType; + } + }); + if (primaryCodecMime && track.kind === Track.Kind.Video) { + const updatedCodec = mimeTypeToVideoCodecString(primaryCodecMime); + if (updatedCodec !== videoCodec) { + self.log.debug('falling back to server selected codec', { + ...self.logContext, + ...getLogContextFromTrack(track), + codec: updatedCodec, + }); + opts.videoCodec = updatedCodec; + + // recompute encodings since bitrates/etc could have changed + encodings = computeVideoEncodings( + track.source === Track.Source.ScreenShare, + req.width, + req.height, + opts, + ); + } } - } - await negotiate(); - } + return negotiate().map(() => ti); + }); + } + + const ti = yield* await trackInfoResult; + + const publication = new LocalTrackPublication(track.kind, ti, track, { - loggerName: this.roomOptions.loggerName, - loggerContextCb: () => this.logContext, + loggerName: self.roomOptions.loggerName, + loggerContextCb: () => self.logContext, }); publication.on(TrackEvent.CpuConstrained, (constrainedTrack) => - this.onTrackCpuConstrained(constrainedTrack, publication), + self.onTrackCpuConstrained(constrainedTrack, publication), ); // save options for when it needs to be republished again publication.options = opts; track.sid = ti.sid; - this.log.debug(`publishing ${track.kind} with encodings`, { - ...this.logContext, + self.log.debug(`publishing ${track.kind} with encodings`, { + ...self.logContext, encodings, trackInfo: ti, }); if (isLocalVideoTrack(track)) { - track.startMonitor(this.engine.client); + track.startMonitor(self.engine.client); } else if (isLocalAudioTrack(track)) { track.startMonitor(); } - this.addTrackPublication(publication); + self.addTrackPublication(publication); // send event for publication - this.emit(ParticipantEvent.LocalTrackPublished, publication); + self.emit(ParticipantEvent.LocalTrackPublished, publication); if ( isLocalAudioTrack(track) && @@ -1296,14 +1305,14 @@ export default class LocalParticipant extends Participant { const stream = track.getPreConnectBuffer(); const mimeType = track.getPreConnectBufferMimeType(); // TODO: we're registering the listener after negotiation, so there might be a race - this.on(ParticipantEvent.LocalTrackSubscribed, (pub) => { + self.on(ParticipantEvent.LocalTrackSubscribed, (pub) => { if (pub.trackSid === ti.sid) { if (!track.hasPreConnectBuffer) { - this.log.warn('subscribe event came to late, buffer already closed', this.logContext); + self.log.warn('subscribe event came to late, buffer already closed', self.logContext); return; } - this.log.debug('finished recording preconnect buffer', { - ...this.logContext, + self.log.debug('finished recording preconnect buffer', { + ...self.logContext, ...getLogContextFromTrack(track), }); track.stopPreConnectBuffer(); @@ -1313,20 +1322,20 @@ export default class LocalParticipant extends Participant { if (stream) { const bufferStreamPromise = new Promise(async (resolve, reject) => { try { - this.log.debug('waiting for agent', { - ...this.logContext, + self.log.debug('waiting for agent', { + ...self.logContext, ...getLogContextFromTrack(track), }); const agentActiveTimeout = setTimeout(() => { reject(new Error('agent not active within 10 seconds')); }, 10_000); - const agent = await this.waitUntilActiveAgentPresent(); + const agent = await self.waitUntilActiveAgentPresent(); clearTimeout(agentActiveTimeout); - this.log.debug('sending preconnect buffer', { - ...this.logContext, + self.log.debug('sending preconnect buffer', { + ...self.logContext, ...getLogContextFromTrack(track), }); - const writer = await this.streamBytes({ + const writer = await self.streamBytes({ name: 'preconnect-buffer', mimeType, topic: 'lk.agent.pre-connect-audio-buffer', @@ -1348,23 +1357,26 @@ export default class LocalParticipant extends Participant { }); bufferStreamPromise .then(() => { - this.log.debug('preconnect buffer sent successfully', { - ...this.logContext, + self.log.debug('preconnect buffer sent successfully', { + ...self.logContext, ...getLogContextFromTrack(track), }); }) .catch((e) => { - this.log.error('error sending preconnect buffer', { - ...this.logContext, + self.log.error('error sending preconnect buffer', { + ...self.logContext, ...getLogContextFromTrack(track), error: e, }); }); } } - return publication; + return ok(publication); + + }); } + override get isLocal(): boolean { return true; } diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index 5e632f36a0..b78ae8a753 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -1,4 +1,5 @@ import { Mutex } from '@livekit/mutex'; +import { Result, err, ok } from 'neverthrow'; import { debounce } from 'ts-debounce'; import { getBrowser } from '../../utils/browserParser'; import DeviceManager from '../DeviceManager'; @@ -211,7 +212,9 @@ export default abstract class LocalTrack< } } - async waitForDimensions(timeout = DEFAULT_DIMENSIONS_TIMEOUT): Promise { + async waitForDimensions( + timeout = DEFAULT_DIMENSIONS_TIMEOUT, + ): Promise> { if (this.kind === Track.Kind.Audio) { throw new Error('cannot get dimensions for audio tracks'); } @@ -226,11 +229,11 @@ export default abstract class LocalTrack< while (Date.now() - started < timeout) { const dims = this.dimensions; if (dims) { - return dims; + return ok(dims); } await sleep(50); } - throw new TrackInvalidError('unable to get track dimensions after timeout'); + return err(new TrackInvalidError('unable to get track dimensions after timeout')); } async setDeviceId(deviceId: ConstrainDOMString): Promise { From 0833846129a3ef2021aa1e8a88bb539a7562cb62 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 15:23:50 +0100 Subject: [PATCH 31/58] Adapt result for main paths of RTCEngine --- src/room/PCTransport.ts | 27 +- src/room/RTCEngine.ts | 56 +-- src/room/participant/LocalParticipant.ts | 522 ++++++++++++----------- src/room/track/LocalTrack.ts | 4 +- 4 files changed, 323 insertions(+), 286 deletions(-) diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index e3fcc5f6a1..adbc4d8072 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -1,5 +1,6 @@ import { Mutex } from '@livekit/mutex'; import { EventEmitter } from 'events'; +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; import type { MediaDescription, SessionDescription } from 'sdp-transform'; import { parse, write } from 'sdp-transform'; import { debounce } from 'ts-debounce'; @@ -375,19 +376,35 @@ export default class PCTransport extends EventEmitter { return this.pc.createDataChannel(label, dataChannelDict); } - addTransceiver(mediaStreamTrack: MediaStreamTrack, transceiverInit: RTCRtpTransceiverInit) { - return this.pc.addTransceiver(mediaStreamTrack, transceiverInit); + addTransceiver( + mediaStreamTrack: MediaStreamTrack, + transceiverInit: RTCRtpTransceiverInit, + ): ResultAsync { + return ResultAsync.fromPromise( + // wrapping this awkwardly as an async IIFE is required as `addTransceiver` is async in react native + (async () => { + const res = await this.pc.addTransceiver(mediaStreamTrack, transceiverInit); + return res; + })(), + (e) => e as TypeError | RangeError | DOMException, + ); } addTransceiverOfKind(kind: 'audio' | 'video', transceiverInit: RTCRtpTransceiverInit) { return this.pc.addTransceiver(kind, transceiverInit); } - addTrack(track: MediaStreamTrack) { + addTrack( + track: MediaStreamTrack, + ): ResultAsync { if (!this._pc) { - throw new UnexpectedConnectionState('PC closed, cannot add track'); + return errAsync(new UnexpectedConnectionState('PC closed, cannot add track')); + } + try { + return okAsync(this._pc.addTrack(track)); + } catch (e: unknown) { + return errAsync(e as DOMException); } - return this._pc.addTrack(track); } setTrackCodecBitrate(info: TrackBitrateInfo) { diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 863586dd94..4d0c068f4d 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -820,21 +820,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.updateAndEmitDCBufferStatus(channelKind); }; - async createSender( + createSender( track: LocalTrack, opts: TrackPublishOptions, encodings?: RTCRtpEncodingParameters[], - ) { + ): ResultAsync { if (supportsTransceiver()) { - const sender = await this.createTransceiverRTCRtpSender(track, opts, encodings); - return sender; + return this.createTransceiverRTCRtpSender(track, opts, encodings); } if (supportsAddTrack()) { this.log.warn('using add-track fallback', this.logContext); - const sender = await this.createRTCRtpSender(track.mediaStreamTrack); - return sender; + return this.createRTCRtpSender(track.mediaStreamTrack); } - throw new UnexpectedConnectionState('Required webRTC APIs not supported on this device'); + return errAsync( + new UnexpectedConnectionState('Required webRTC APIs not supported on this device'), + ); } async createSimulcastSender( @@ -855,13 +855,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit throw new UnexpectedConnectionState('Cannot stream on this device'); } - private async createTransceiverRTCRtpSender( + private createTransceiverRTCRtpSender( track: LocalTrack, opts: TrackPublishOptions, encodings?: RTCRtpEncodingParameters[], - ) { + ): ResultAsync { if (!this.pcManager) { - throw new UnexpectedConnectionState('publisher is closed'); + return errAsync(new UnexpectedConnectionState('publisher is closed')); } const streams: MediaStream[] = []; @@ -879,15 +879,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit transceiverInit.sendEncodings = encodings; } // addTransceiver for react-native is async. web is synchronous, but await won't effect it. - const transceiver = await this.pcManager.addPublisherTransceiver( - track.mediaStreamTrack, - transceiverInit, - ); + const senderResult = this.pcManager + .addPublisherTransceiver(track.mediaStreamTrack, transceiverInit) + .map((transceiver) => transceiver.sender); - return transceiver.sender; + return senderResult; } - private async createSimulcastTransceiverSender( + private createSimulcastTransceiverSender( track: LocalVideoTrack, simulcastTrack: SimulcastTrackInfo, opts: TrackPublishOptions, @@ -901,20 +900,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit transceiverInit.sendEncodings = encodings; } // addTransceiver for react-native is async. web is synchronous, but await won't effect it. - const transceiver = await this.pcManager.addPublisherTransceiver( - simulcastTrack.mediaStreamTrack, - transceiverInit, - ); - if (!opts.videoCodec) { - return; - } - track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender); - return transceiver.sender; + const senderResult = this.pcManager + .addPublisherTransceiver(simulcastTrack.mediaStreamTrack, transceiverInit) + .map((transceiver) => { + if (opts.videoCodec) { + track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender); + } + return transceiver.sender; + }); + + return senderResult; } - private async createRTCRtpSender(track: MediaStreamTrack) { + private createRTCRtpSender( + track: MediaStreamTrack, + ): ResultAsync { if (!this.pcManager) { - throw new UnexpectedConnectionState('publisher is closed'); + return errAsync(new UnexpectedConnectionState('publisher is closed')); } return this.pcManager.addPublisherTrack(track); } diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 2f6af1f3c7..30546143fc 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -22,6 +22,7 @@ import { UserPacket, protoInt64, } from '@livekit/protocol'; +import { ResultAsync, errAsync, ok, safeTry } from 'neverthrow'; import { SignalConnectionState } from '../../api/SignalClient'; import type { InternalRoomOptions } from '../../options'; import { PCTransportState } from '../PCTransportManager'; @@ -30,7 +31,6 @@ import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingData import type { TextStreamWriter } from '../data-stream/outgoing/StreamWriter'; import { defaultVideoCodec } from '../defaults'; import { - ConnectionError, DeviceUnsupportedError, LivekitError, NegotiationError, @@ -105,7 +105,6 @@ import { computeVideoEncodings, getDefaultDegradationPreference, } from './publishUtils'; -import { errAsync, ok, ResultAsync, safeTry } from 'neverthrow'; export default class LocalParticipant extends Participant { audioTrackPublications: Map; @@ -745,7 +744,10 @@ export default class LocalParticipant extends Participant { * @param track * @param options */ - async publishTrack(track: LocalTrack | MediaStreamTrack, options?: TrackPublishOptions) { + async publishTrack( + track: LocalTrack | MediaStreamTrack, + options?: TrackPublishOptions, + ): Promise { return this.publishOrRepublishTrack(track, options); } @@ -902,12 +904,20 @@ export default class LocalParticipant extends Participant { if (publicationTimedOut) { return; } - const publication = await this.publish(track, opts, isStereo); - resolve(publication); + const publicationResult = await this.publish(track, opts, isStereo); + if (publicationResult.isErr()) { + reject(publicationResult.error); + } else { + resolve(publicationResult.value); + } } else { try { - const publication = await this.publish(track, opts, isStereo); - resolve(publication); + const publicationResult = await this.publish(track, opts, isStereo); + if (publicationResult.isErr()) { + reject(publicationResult.error); + } else { + resolve(publicationResult.value); + } } catch (e) { reject(e); } @@ -957,10 +967,18 @@ export default class LocalParticipant extends Participant { return false; } - private publish(track: LocalTrack, opts: TrackPublishOptions, isStereo: boolean): ResultAsync { - + private publish( + track: LocalTrack, + opts: TrackPublishOptions, + isStereo: boolean, + ): ResultAsync< + LocalTrackPublication, + PublishTrackError | TrackInvalidError | TypeError | RangeError | DOMException + > { if (!this.hasPermissionsToPublish(track)) { - return errAsync(new PublishTrackError('failed to publish track, insufficient permissions', 403)); + return errAsync( + new PublishTrackError('failed to publish track, insufficient permissions', 403), + ); } const existingTrackOfSource = Array.from(this.trackPublications.values()).find( (publishedTrack) => isLocalTrack(track) && publishedTrack.source === track.source, @@ -1055,166 +1073,172 @@ export default class LocalParticipant extends Participant { const self = this; - return safeTry(async function*() { - // compute encodings and layers for video - let encodings: RTCRtpEncodingParameters[] | undefined; - if (track.kind === Track.Kind.Video) { - - const dims = yield* (await track.waitForDimensions()).orElse(() => { - const defaultRes = - self.roomOptions.videoCaptureDefaults?.resolution ?? VideoPresets.h720.resolution; - - // use defaults, it's quite painful for congestion control without simulcast - // so using default dims according to publish settings - const defaultDims = { - width: defaultRes.width, - height: defaultRes.height, - } satisfies Track.Dimensions; - - // log failure - self.log.error('could not determine track dimensions, using defaults', { - ...self.logContext, - ...getLogContextFromTrack(track), - dims, + return safeTry< + LocalTrackPublication, + PublishTrackError | TrackInvalidError | TypeError | RangeError | DOMException + >(async function* () { + // compute encodings and layers for video + let encodings: RTCRtpEncodingParameters[] | undefined; + if (track.kind === Track.Kind.Video) { + const dims = yield* (await track.waitForDimensions()).orElse(() => { + const defaultRes = + self.roomOptions.videoCaptureDefaults?.resolution ?? VideoPresets.h720.resolution; + + // use defaults, it's quite painful for congestion control without simulcast + // so using default dims according to publish settings + const defaultDims = { + width: defaultRes.width, + height: defaultRes.height, + } satisfies Track.Dimensions; + + // log failure + self.log.error('could not determine track dimensions, using defaults', { + ...self.logContext, + ...getLogContextFromTrack(track), + dims, + }); + return ok(defaultDims as Track.Dimensions); }); - return ok(defaultDims as Track.Dimensions); - }) - - // width and height should be defined for video - req.width = dims.width; - req.height = dims.height; - // for svc codecs, disable simulcast and use vp8 for backup codec - if (isLocalVideoTrack(track)) { - if (isSVCCodec(videoCodec)) { - if (track.source === Track.Source.ScreenShare) { - // vp9 svc with screenshare cannot encode multiple spatial layers - // doing so reduces publish resolution to minimal resolution - opts.scalabilityMode = 'L1T3'; - // Chrome does not allow more than 5 fps with L1T3, and it has encoding bugs with L3T3 - // It has a different path for screenshare handling and it seems to be untested/buggy - // As a workaround, we are setting contentHint to force it to go through the same - // path as regular camera video. While this is not optimal, it delivers the performance - // that we need - if ('contentHint' in track.mediaStreamTrack) { - track.mediaStreamTrack.contentHint = 'motion'; - self.log.info('forcing contentHint to motion for screenshare with SVC codecs', { - ...self.logContext, - ...getLogContextFromTrack(track), - }); + + // width and height should be defined for video + req.width = dims.width; + req.height = dims.height; + // for svc codecs, disable simulcast and use vp8 for backup codec + if (isLocalVideoTrack(track)) { + if (isSVCCodec(videoCodec)) { + if (track.source === Track.Source.ScreenShare) { + // vp9 svc with screenshare cannot encode multiple spatial layers + // doing so reduces publish resolution to minimal resolution + opts.scalabilityMode = 'L1T3'; + // Chrome does not allow more than 5 fps with L1T3, and it has encoding bugs with L3T3 + // It has a different path for screenshare handling and it seems to be untested/buggy + // As a workaround, we are setting contentHint to force it to go through the same + // path as regular camera video. While this is not optimal, it delivers the performance + // that we need + if ('contentHint' in track.mediaStreamTrack) { + track.mediaStreamTrack.contentHint = 'motion'; + self.log.info('forcing contentHint to motion for screenshare with SVC codecs', { + ...self.logContext, + ...getLogContextFromTrack(track), + }); + } } + // set scalabilityMode to 'L3T3_KEY' by default + opts.scalabilityMode = opts.scalabilityMode ?? 'L3T3_KEY'; } - // set scalabilityMode to 'L3T3_KEY' by default - opts.scalabilityMode = opts.scalabilityMode ?? 'L3T3_KEY'; - } - req.simulcastCodecs = [ - new SimulcastCodec({ - codec: videoCodec, - cid: track.mediaStreamTrack.id, - }), - ]; - - // set up backup - if (opts.backupCodec === true) { - opts.backupCodec = { codec: defaultVideoCodec }; - } - if ( - opts.backupCodec && - videoCodec !== opts.backupCodec.codec && - // TODO remove this once e2ee is supported for backup codecs - req.encryption === Encryption_Type.NONE - ) { - // multi-codec simulcast requires dynacast - if (!self.roomOptions.dynacast) { - self.roomOptions.dynacast = true; - } - req.simulcastCodecs.push( + req.simulcastCodecs = [ new SimulcastCodec({ - codec: opts.backupCodec.codec, - cid: '', + codec: videoCodec, + cid: track.mediaStreamTrack.id, }), - ); - } - } + ]; - encodings = computeVideoEncodings( - track.source === Track.Source.ScreenShare, - req.width, - req.height, - opts, - ); - req.layers = videoLayersFromEncodings( - req.width, - req.height, - encodings, - isSVCCodec(opts.videoCodec), - ); - } else if (track.kind === Track.Kind.Audio) { - encodings = [ - { - maxBitrate: opts.audioPreset?.maxBitrate, - priority: opts.audioPreset?.priority ?? 'high', - networkPriority: opts.audioPreset?.priority ?? 'high', - }, - ]; - } + // set up backup + if (opts.backupCodec === true) { + opts.backupCodec = { codec: defaultVideoCodec }; + } + if ( + opts.backupCodec && + videoCodec !== opts.backupCodec.codec && + // TODO remove this once e2ee is supported for backup codecs + req.encryption === Encryption_Type.NONE + ) { + // multi-codec simulcast requires dynacast + if (!self.roomOptions.dynacast) { + self.roomOptions.dynacast = true; + } + req.simulcastCodecs.push( + new SimulcastCodec({ + codec: opts.backupCodec.codec, + cid: '', + }), + ); + } + } - if (!self.engine || self.engine.isClosed) { - throw new UnexpectedConnectionState('cannot publish track when not connected'); - } + encodings = computeVideoEncodings( + track.source === Track.Source.ScreenShare, + req.width, + req.height, + opts, + ); + req.layers = videoLayersFromEncodings( + req.width, + req.height, + encodings, + isSVCCodec(opts.videoCodec), + ); + } else if (track.kind === Track.Kind.Audio) { + encodings = [ + { + maxBitrate: opts.audioPreset?.maxBitrate, + priority: opts.audioPreset?.priority ?? 'high', + networkPriority: opts.audioPreset?.priority ?? 'high', + }, + ]; + } - const negotiate = (): ResultAsync => { - if (!self.engine.pcManager) { - return errAsync(new UnexpectedConnectionState('pcManager is not ready')); + if (!self.engine || self.engine.isClosed) { + throw new UnexpectedConnectionState('cannot publish track when not connected'); } - track.sender = await self.engine.createSender(track, opts, encodings); - self.emit(ParticipantEvent.LocalSenderCreated, track.sender, track); + const negotiate = () => + safeTry< + void, + NegotiationError | UnexpectedConnectionState | TypeError | RangeError | DOMException + >(async function* () { + if (!self.engine.pcManager) { + return errAsync(new UnexpectedConnectionState('pcManager is not ready')); + } - if (isLocalVideoTrack(track)) { - opts.degradationPreference ??= getDefaultDegradationPreference(track); - track.setDegradationPreference(opts.degradationPreference); - } + track.sender = yield* await self.engine.createSender(track, opts, encodings); + self.emit(ParticipantEvent.LocalSenderCreated, track.sender, track); - if (encodings) { - if (isFireFox() && track.kind === Track.Kind.Audio) { - /* Refer to RFC https://datatracker.ietf.org/doc/html/rfc7587#section-6.1, + if (isLocalVideoTrack(track)) { + opts.degradationPreference ??= getDefaultDegradationPreference(track); + track.setDegradationPreference(opts.degradationPreference); + } + + if (encodings) { + if (isFireFox() && track.kind === Track.Kind.Audio) { + /* Refer to RFC https://datatracker.ietf.org/doc/html/rfc7587#section-6.1, livekit-server uses maxaveragebitrate=510000 in the answer sdp to permit client to publish high quality audio track. But firefox always uses this value as the actual bitrates, causing the audio bitrates to rise to 510Kbps in any stereo case unexpectedly. So the client need to modify maxaverragebitrates in answer sdp to user provided value to fix the issue. */ - let trackTransceiver: RTCRtpTransceiver | undefined = undefined; - for (const transceiver of this.engine.pcManager.publisher.getTransceivers()) { - if (transceiver.sender === track.sender) { - trackTransceiver = transceiver; - break; + let trackTransceiver: RTCRtpTransceiver | undefined = undefined; + for (const transceiver of self.engine.pcManager.publisher.getTransceivers()) { + if (transceiver.sender === track.sender) { + trackTransceiver = transceiver; + break; + } + } + if (trackTransceiver) { + self.engine.pcManager.publisher.setTrackCodecBitrate({ + transceiver: trackTransceiver, + codec: 'opus', + maxbr: encodings[0]?.maxBitrate ? encodings[0].maxBitrate / 1000 : 0, + }); + } + } else if (track.codec && isSVCCodec(track.codec) && encodings[0]?.maxBitrate) { + self.engine.pcManager.publisher.setTrackCodecBitrate({ + cid: req.cid, + codec: track.codec, + maxbr: encodings[0].maxBitrate / 1000, + }); } } - if (trackTransceiver) { - this.engine.pcManager.publisher.setTrackCodecBitrate({ - transceiver: trackTransceiver, - codec: 'opus', - maxbr: encodings[0]?.maxBitrate ? encodings[0].maxBitrate / 1000 : 0, - }); - } - } else if (track.codec && isSVCCodec(track.codec) && encodings[0]?.maxBitrate) { - this.engine.pcManager.publisher.setTrackCodecBitrate({ - cid: req.cid, - codec: track.codec, - maxbr: encodings[0].maxBitrate / 1000, - }); - } - } - return this.engine.negotiate(); - }; + return self.engine.negotiate(); + }); - const addTrackResult = self.engine.addTrack(req); + const addTrackResult = self.engine.addTrack(req); - const addTrackWithFallback = addTrackResult.mapErr((e) => { - if (track.sender && self.engine.pcManager?.publisher) { + const addTrackWithFallback = addTrackResult.mapErr((e) => { + if (track.sender && self.engine.pcManager?.publisher) { self.engine.pcManager.publisher.removeTrack(track.sender); self.engine.negotiate().orTee((negotiateErr) => { self.log.error( @@ -1228,14 +1252,15 @@ export default class LocalParticipant extends Participant { }); } return e; - }); + }); - let trackInfoResult: ResultAsync + let ti: TrackInfo; + + if (self.enabledPublishVideoCodecs.length > 0) { + ti = yield* await ResultAsync.combine([addTrackWithFallback, negotiate()]).map((t) => t[0]); + } else { + ti = yield* await addTrackWithFallback; - if (self.enabledPublishVideoCodecs.length > 0) { - trackInfoResult = ResultAsync.combine([addTrackWithFallback, negotiate()]).map(t => t[0]); - } else { - trackInfoResult = addTrackWithFallback.andThen((ti) => { // server might not support the codec the client has requested, in that case, fallback // to a supported codec let primaryCodecMime: string | undefined; @@ -1263,120 +1288,113 @@ export default class LocalParticipant extends Participant { ); } } - return negotiate().map(() => ti); - }); - } - - const ti = yield* await trackInfoResult; - - - - const publication = new LocalTrackPublication(track.kind, ti, track, { - loggerName: self.roomOptions.loggerName, - loggerContextCb: () => self.logContext, - }); - publication.on(TrackEvent.CpuConstrained, (constrainedTrack) => - self.onTrackCpuConstrained(constrainedTrack, publication), - ); - // save options for when it needs to be republished again - publication.options = opts; - track.sid = ti.sid; + yield* await negotiate(); + } - self.log.debug(`publishing ${track.kind} with encodings`, { - ...self.logContext, - encodings, - trackInfo: ti, - }); + const publication = new LocalTrackPublication(track.kind, ti, track, { + loggerName: self.roomOptions.loggerName, + loggerContextCb: () => self.logContext, + }); + publication.on(TrackEvent.CpuConstrained, (constrainedTrack) => + self.onTrackCpuConstrained(constrainedTrack, publication), + ); + // save options for when it needs to be republished again + publication.options = opts; + track.sid = ti.sid; - if (isLocalVideoTrack(track)) { - track.startMonitor(self.engine.client); - } else if (isLocalAudioTrack(track)) { - track.startMonitor(); - } + self.log.debug(`publishing ${track.kind} with encodings`, { + ...self.logContext, + encodings, + trackInfo: ti, + }); - self.addTrackPublication(publication); - // send event for publication - self.emit(ParticipantEvent.LocalTrackPublished, publication); + if (isLocalVideoTrack(track)) { + track.startMonitor(self.engine.client); + } else if (isLocalAudioTrack(track)) { + track.startMonitor(); + } - if ( - isLocalAudioTrack(track) && - ti.audioFeatures.includes(AudioTrackFeature.TF_PRECONNECT_BUFFER) - ) { - const stream = track.getPreConnectBuffer(); - const mimeType = track.getPreConnectBufferMimeType(); - // TODO: we're registering the listener after negotiation, so there might be a race - self.on(ParticipantEvent.LocalTrackSubscribed, (pub) => { - if (pub.trackSid === ti.sid) { - if (!track.hasPreConnectBuffer) { - self.log.warn('subscribe event came to late, buffer already closed', self.logContext); - return; - } - self.log.debug('finished recording preconnect buffer', { - ...self.logContext, - ...getLogContextFromTrack(track), - }); - track.stopPreConnectBuffer(); - } - }); + self.addTrackPublication(publication); + // send event for publication + self.emit(ParticipantEvent.LocalTrackPublished, publication); - if (stream) { - const bufferStreamPromise = new Promise(async (resolve, reject) => { - try { - self.log.debug('waiting for agent', { - ...self.logContext, - ...getLogContextFromTrack(track), - }); - const agentActiveTimeout = setTimeout(() => { - reject(new Error('agent not active within 10 seconds')); - }, 10_000); - const agent = await self.waitUntilActiveAgentPresent(); - clearTimeout(agentActiveTimeout); - self.log.debug('sending preconnect buffer', { + if ( + isLocalAudioTrack(track) && + ti.audioFeatures.includes(AudioTrackFeature.TF_PRECONNECT_BUFFER) + ) { + const stream = track.getPreConnectBuffer(); + const mimeType = track.getPreConnectBufferMimeType(); + // TODO: we're registering the listener after negotiation, so there might be a race + self.on(ParticipantEvent.LocalTrackSubscribed, (pub) => { + if (pub.trackSid === ti.sid) { + if (!track.hasPreConnectBuffer) { + self.log.warn('subscribe event came to late, buffer already closed', self.logContext); + return; + } + self.log.debug('finished recording preconnect buffer', { ...self.logContext, ...getLogContextFromTrack(track), }); - const writer = await self.streamBytes({ - name: 'preconnect-buffer', - mimeType, - topic: 'lk.agent.pre-connect-audio-buffer', - destinationIdentities: [agent.identity], - attributes: { - trackId: publication.trackSid, - sampleRate: String(settings.sampleRate ?? '48000'), - channels: String(settings.channelCount ?? '1'), - }, - }); - for await (const chunk of stream) { - await writer.write(chunk); - } - await writer.close(); - resolve(); - } catch (e) { - reject(e); + track.stopPreConnectBuffer(); } }); - bufferStreamPromise - .then(() => { - self.log.debug('preconnect buffer sent successfully', { - ...self.logContext, - ...getLogContextFromTrack(track), - }); - }) - .catch((e) => { - self.log.error('error sending preconnect buffer', { - ...self.logContext, - ...getLogContextFromTrack(track), - error: e, - }); + + if (stream) { + const bufferStreamPromise = new Promise(async (resolve, reject) => { + try { + self.log.debug('waiting for agent', { + ...self.logContext, + ...getLogContextFromTrack(track), + }); + const agentActiveTimeout = setTimeout(() => { + reject(new Error('agent not active within 10 seconds')); + }, 10_000); + const agent = await self.waitUntilActiveAgentPresent(); + clearTimeout(agentActiveTimeout); + self.log.debug('sending preconnect buffer', { + ...self.logContext, + ...getLogContextFromTrack(track), + }); + const writer = await self.streamBytes({ + name: 'preconnect-buffer', + mimeType, + topic: 'lk.agent.pre-connect-audio-buffer', + destinationIdentities: [agent.identity], + attributes: { + trackId: publication.trackSid, + sampleRate: String(settings.sampleRate ?? '48000'), + channels: String(settings.channelCount ?? '1'), + }, + }); + for await (const chunk of stream) { + await writer.write(chunk); + } + await writer.close(); + resolve(); + } catch (e) { + reject(e); + } }); + bufferStreamPromise + .then(() => { + self.log.debug('preconnect buffer sent successfully', { + ...self.logContext, + ...getLogContextFromTrack(track), + }); + }) + .catch((e) => { + self.log.error('error sending preconnect buffer', { + ...self.logContext, + ...getLogContextFromTrack(track), + error: e, + }); + }); + } } - } - return ok(publication); - - }); + return ok(publication); + }); } - override get isLocal(): boolean { return true; } diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index b78ae8a753..10be5a3a8d 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -561,8 +561,8 @@ export default abstract class LocalTrack< error, }); setTimeout(() => { - processorElement.play().catch((err) => { - this.log.error('failed to play processor element', { ...this.logContext, err }); + processorElement.play().catch((e) => { + this.log.error('failed to play processor element', { ...this.logContext, error: e }); }); }, 100); } else { From 96cc1a0fa1bdb45b30de7bacdb9037a5a8ec3256 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 15:44:14 +0100 Subject: [PATCH 32/58] add track --- src/api/SignalClient.ts | 8 +++++++- src/room/RTCEngine.ts | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index ac98a2bd9b..e2383df2e1 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -257,6 +257,7 @@ export class SignalClient { ConnectionError.internal('attempted to reconnect without signal options being set'), ); } + console.warn('reconnecting signal'); this.state = SignalConnectionState.RECONNECTING; // clear ping interval and restart it once reconnected this.clearPingInterval(); @@ -933,13 +934,18 @@ export class SignalClient { ); } else if (!isReconnect) { // non-reconnect case, should receive join response first - return err( ConnectionError.internal( `did not receive join response, got ${firstSignalResponse.message?.case} instead`, ), ); } + console.warn('first message', { + msg: firstSignalResponse, + isReconnect, + state: this.state, + stateName: SignalConnectionState[this.state], + }); return err(ConnectionError.internal('Unexpected first message')); } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 4d0c068f4d..3d6d4d706c 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -381,6 +381,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit (e) => e as ReturnType, ); + // TODO: this should probably be a result and happen before addTrackResult is returned + this.client.sendAddTrack(req); + const addTrackResult = withFinally(withTimeout(pendingPromiseResult, 10_000), () => { delete this.pendingTrackResolvers[req.cid]; }); @@ -1012,6 +1015,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit recoverable = false; } else if (!(error instanceof SignalReconnectError)) { // cannot resume + console.warn('cannot resume, error is', error); this.fullReconnectOnNext = true; } @@ -1491,6 +1495,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const negotiationWithErrorHandler = closedOrNegotiate.orTee((e) => { if (e instanceof NegotiationError) { + console.warn('cannot resume after negotiation error, error is', e); this.fullReconnectOnNext = true; } this.handleDisconnect('negotiation', ReconnectReason.RR_UNKNOWN); From 57df4cce2ca0bed347ea142c71eef9254d04cff4 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 15:48:01 +0100 Subject: [PATCH 33/58] need to await close --- src/api/SignalClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index ac98a2bd9b..6177b59a9e 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -761,10 +761,10 @@ export class SignalClient { } } - private handleOnClose(reason: string) { + private async handleOnClose(reason: string) { if (this.state === SignalConnectionState.DISCONNECTED) return; const onCloseCallback = this.onClose; - this.close(undefined, reason); + await this.close(undefined, reason); this.log.debug(`websocket connection closing: ${reason}`, { ...this.logContext, reason }); if (onCloseCallback) { onCloseCallback(reason); From b4d29c517567f38ec62c5a03865826f79cc5105e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 17 Nov 2025 16:52:31 +0100 Subject: [PATCH 34/58] wip simulate scenarios --- src/api/SignalClient.ts | 105 +++++++++++++++++---------------- src/room/PCTransport.ts | 4 +- src/room/PCTransportManager.ts | 6 +- src/room/RTCEngine.ts | 2 +- 4 files changed, 61 insertions(+), 56 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 6177b59a9e..b73a160be5 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -87,7 +87,6 @@ type SignalKind = NonNullable['case']; const passThroughQueueSignals: Array = [ 'syncState', 'trickle', - 'offer', 'answer', 'simulate', 'leave', @@ -328,7 +327,7 @@ export class SignalClient { }); const firstMessageOrClose = raceResults([ - self.processInitialSignalMessage(wsConnection, isReconnect), + self.processInitialSignalMessage(wsConnection), // Return the close promise as error if it resolves first ws!.closed .orTee((error) => { @@ -360,21 +359,42 @@ export class SignalClient { }), ]); - const result = withAbort(withTimeout(firstMessageOrClose, 5_000), abortSignal).orTee( - (error) => { - if (error.reason === ConnectionErrorReason.Cancelled) { - self - .sendLeave() - .then(() => self.close()) - .catch((e) => { - self.log.error(e); - self.close(); - }); - } - }, + const firstSignalResponse = yield* await withAbort( + withTimeout(firstMessageOrClose, 5_000), + abortSignal, + ).orTee((error) => { + if (error.reason === ConnectionErrorReason.Cancelled) { + self + .sendLeave() + .then(() => self.close()) + .catch((e) => { + self.log.error(e); + self.close(); + }); + } + }); + + const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); + + // Handle join response - set up ping configuration + if (firstSignalResponse.message?.case === 'join') { + self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; + self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; + if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { + self.log.debug('ping config', { + ...self.logContext, + timeout: self.pingTimeoutDuration, + interval: self.pingIntervalDuration, + }); + } + } + + self.handleSignalConnected( + wsConnection, + validation.shouldProcessFirstMessage ? firstSignalResponse : undefined, ); - return result; + return ok(validation.response as U); }), this.connectionLock, ); @@ -432,22 +452,24 @@ export class SignalClient { this.state = SignalConnectionState.DISCONNECTING; } if (this.ws) { - this.ws.close({ closeCode: 1000, reason }); + const ws = this.ws; + this.ws = undefined; + this.streamWriter = undefined; + ws.close({ closeCode: 1000, reason }); // calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED - const closePromise = this.ws.closed.match( + const closePromise = ws.closed.match( (closeInfo) => closeInfo, (error) => error, ); - this.ws = undefined; - this.streamWriter = undefined; + await Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]); this.log.info('closed websocket', { reason }); } } catch (e) { this.log.debug('websocket error while closing', { ...this.logContext, error: e }); } finally { - if (updateState) { + if (updateState && this.state === SignalConnectionState.DISCONNECTING) { this.state = SignalConnectionState.DISCONNECTED; } unlock(); @@ -456,8 +478,13 @@ export class SignalClient { // initial offer after joining sendOffer(offer: RTCSessionDescriptionInit, offerId: number) { - this.log.debug('sending offer', { ...this.logContext, offerSdp: offer.sdp }); - this.sendRequest({ + this.log.debug('sending offer', { + ...this.logContext, + offerSdp: offer.sdp, + state: SignalConnectionState[this.state], + wsState: this.ws?.readyState, + }); + return this.sendRequest({ case: 'offer', value: toProtoSessionDescription(offer, offerId), }); @@ -834,19 +861,19 @@ export class SignalClient { * @internal */ private handleSignalConnected(connection: WebSocketConnection, firstMessage?: SignalResponse) { + console.warn('signal back connected'); this.state = SignalConnectionState.CONNECTED; this.startPingInterval(); this.startReadingLoop(connection.readable.getReader(), firstMessage); } - private processInitialSignalMessage< - T extends boolean, - U extends T extends false ? JoinResponse : ReconnectResponse | undefined, - >(connection: WebSocketConnection, isReconnect: T): ResultAsync { + private processInitialSignalMessage( + connection: WebSocketConnection, + ): ResultAsync { const self = this; // TODO: If inferring from the return type this could be more granular here than ConnectionError - return safeTry(async function* () { + return safeTry(async function* () { const signalReader = connection.readable.getReader(); self.streamWriter = connection.writable.getWriter(); @@ -857,29 +884,7 @@ export class SignalClient { const firstSignalResponse = parseSignalResponse(firstMessage.value); - // Validate the first message - const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); - - // Handle join response - set up ping configuration - if (firstSignalResponse.message?.case === 'join') { - self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; - self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; - if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { - self.log.debug('ping config', { - ...self.logContext, - timeout: self.pingTimeoutDuration, - interval: self.pingIntervalDuration, - }); - } - } - - // Handle successful connection - const firstMessageToProcess = validation.shouldProcessFirstMessage - ? firstSignalResponse - : undefined; - self.handleSignalConnected(connection, firstMessageToProcess); - - return okAsync(validation.response as U); + return okAsync(firstSignalResponse); }); } diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index e3fcc5f6a1..304979a9a0 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -67,7 +67,7 @@ export default class PCTransport extends EventEmitter { remoteNackMids: string[] = []; - onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void; + onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => Promise; onIceCandidate?: (candidate: RTCIceCandidate) => void; @@ -352,7 +352,7 @@ export default class PCTransport extends EventEmitter { return; } await this.setMungedSDP(offer, write(sdpParsed)); - this.onOffer(offer, this.latestOfferId); + await this.onOffer(offer, this.latestOfferId); } finally { unlock(); } diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index e4d7f6709f..ee9d8e3982 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -49,7 +49,7 @@ export class PCTransportManager { public onTrack?: (ev: RTCTrackEvent) => void; - public onPublisherOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void; + public onPublisherOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => Promise; private isPublisherConnectionRequired: boolean; @@ -99,8 +99,8 @@ export class PCTransportManager { this.onTrack?.(ev); }; - this.publisher.onOffer = (offer, offerId) => { - this.onPublisherOffer?.(offer, offerId); + this.publisher.onOffer = async (offer, offerId) => { + return this.onPublisherOffer?.(offer, offerId); }; this.state = PCTransportState.NEW; diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 6365c4d096..0f53ccac1d 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -451,7 +451,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit }; this.pcManager.onPublisherOffer = (offer, offerId) => { - this.client.sendOffer(offer, offerId); + return this.client.sendOffer(offer, offerId); }; this.pcManager.onDataChannel = this.handleDataChannel; From 51b331879ec3696ac56b6f06b9e0d41c3ae7ef3b Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 10:53:37 +0100 Subject: [PATCH 35/58] ws disconnect during resume working --- src/api/SignalClient.ts | 66 ++++++++++++++++++++++++------------- src/api/WebSocketStream.ts | 6 ++++ src/api/utils.ts | 1 + src/room/RTCEngine.ts | 67 ++++++++++++++++++++++---------------- 4 files changed, 89 insertions(+), 51 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index b73a160be5..c507f1bf71 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -301,26 +301,37 @@ export class SignalClient { const ws = new WebSocketStream(rtcUrl); self.ws = ws; - const wsConnectionResult = withTimeout(self.ws.opened, opts.websocketTimeout).mapErr( - async (error) => { - // retrieve info about what error was causing the connection failure and enhance the returned error - if (self.state !== SignalConnectionState.CONNECTED) { - self.state = SignalConnectionState.DISCONNECTED; - const connectionError = await withAbort( - withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), - abortSignal, - ); - - const closeReason = `${error.reason}: ${error.message}`; - - self.close(undefined, closeReason); - if (connectionError.isErr()) { - return connectionError.error; - } + console.warn('set new ws on self with state', ws.readyState); + setInterval(() => { + if (ws === self.ws) { + console.warn('ws with connection state', ws.readyState, ws === self.ws); + } + }, 100); + + const wsConnectionResult = withTimeout( + ws.opened.andTee((connection) => { + console.warn('setting stream writer'); + self.streamWriter = connection.writable.getWriter(); + }), + opts.websocketTimeout, + ).mapErr(async (error) => { + // retrieve info about what error was causing the connection failure and enhance the returned error + if (self.state !== SignalConnectionState.CONNECTED) { + self.state = SignalConnectionState.DISCONNECTED; + const connectionError = await withAbort( + withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), + abortSignal, + ); + + const closeReason = `${error.reason}: ${error.message}`; + + self.close(undefined, closeReason); + if (connectionError.isErr()) { + return connectionError.error; } - return error; - }, - ); + } + return error; + }); const wsConnection = yield* withAbort(wsConnectionResult, abortSignal).orTee((error) => { self.close(undefined, error.message); @@ -334,8 +345,12 @@ export class SignalClient { self.handleWSError(error); }) .andThen((closeInfo) => { + if (ws === self.ws) { + console.warn('result websocket closed, should do a reconnect now', { + closeInfo, + }); + } if ( - closeInfo.closeCode !== 1000 && // we only log the warning here if the current ws connection is still the same, we don't care about closing of older ws connections that have been replaced ws === self.ws ) { @@ -348,6 +363,11 @@ export class SignalClient { }); if (self.state == SignalConnectionState.CONNECTED) { self.handleOnClose(closeInfo.reason ?? 'Websocket closed unexpectedly'); + } else { + console.warn( + 'ws closed unexpectedly in state', + SignalConnectionState[self.state], + ); } } @@ -445,7 +465,10 @@ export class SignalClient { this.log.debug(`ignoring signal close as it's already in disconnecting state`); return; } + console.warn('queuing ws close'); const unlock = await this.closingLock.lock(); + console.warn('performing ws close'); + try { this.clearPingInterval(); if (updateState) { @@ -870,12 +893,9 @@ export class SignalClient { private processInitialSignalMessage( connection: WebSocketConnection, ): ResultAsync { - const self = this; - // TODO: If inferring from the return type this could be more granular here than ConnectionError return safeTry(async function* () { const signalReader = connection.readable.getReader(); - self.streamWriter = connection.writable.getWriter(); const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); if (!firstMessage.value) { diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index 33affa9cec..a5d1f2c4d8 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -141,7 +141,13 @@ export class WebSocketStream { + console.warn('ws closed'); resolve({ closeCode: code, reason }); ws.removeEventListener('error', errorHandler); }; diff --git a/src/api/utils.ts b/src/api/utils.ts index d5766d522b..16bb57516c 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,6 +1,7 @@ import type { Mutex } from '@livekit/mutex'; import { SignalResponse } from '@livekit/protocol'; import { Result, ResultAsync, errAsync } from 'neverthrow'; +import type { RegionUrlProvider } from '../room/RegionUrlProvider'; import { ConnectionError } from '../room/errors'; import { toHttpUrl, toWebsocketUrl } from '../room/utils'; diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 0f53ccac1d..ac7a788b7f 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -39,7 +39,7 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; -import { type Result, err, ok } from 'neverthrow'; +import { type Result, ResultAsync, err, errAsync, ok, safeTry } from 'neverthrow'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; @@ -1041,30 +1041,31 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private async restartConnection( regionUrl?: string, ): Promise> { - try { - if (!this.url || !this.token) { + const self = this; + const restartResultAsync = safeTry(async function* () { + if (!self.url || !self.token) { // permanent failure, don't attempt reconnection return err(new UnexpectedConnectionState('could not reconnect, url or token not saved')); } - this.log.info(`reconnecting, attempt: ${this.reconnectAttempts}`, this.logContext); - this.emit(EngineEvent.Restarting); + self.log.info(`reconnecting, attempt: ${self.reconnectAttempts}`, self.logContext); + self.emit(EngineEvent.Restarting); - if (!this.client.isDisconnected) { - await this.client.sendLeave(); + if (!self.client.isDisconnected) { + await self.client.sendLeave(); } - await this.cleanupPeerConnections(); - await this.cleanupClient(); + await self.cleanupPeerConnections(); + await self.cleanupClient(); - if (!this.signalOpts) { - this.log.warn( + if (!self.signalOpts) { + self.log.warn( 'attempted connection restart, without signal options present', - this.logContext, + self.logContext, ); - throw new SignalReconnectError(); + return err(new SignalReconnectError()); } // in case a regionUrl is passed, the region URL takes precedence - const joinResult = await this.join(regionUrl ?? this.url, this.token, this.signalOpts); + const joinResult = await self.join(regionUrl ?? self.url, self.token, self.signalOpts); if (joinResult.isErr()) { const error = joinResult.error; if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.NotAllowed) { @@ -1073,56 +1074,66 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return err(new SignalReconnectError()); } - if (this.shouldFailNext) { - this.shouldFailNext = false; + if (self.shouldFailNext) { + self.shouldFailNext = false; return err(new SimulatedError()); } - this.client.setReconnected(); - this.emit(EngineEvent.SignalRestarted, joinResult.value); + self.client.setReconnected(); + self.emit(EngineEvent.SignalRestarted, joinResult.value); - await this.waitForPCReconnected(); + await self.waitForPCReconnected(); // re-check signal connection state before setting engine as resumed - if (this.client.currentState !== SignalConnectionState.CONNECTED) { + if (self.client.currentState !== SignalConnectionState.CONNECTED) { return err(new SignalReconnectError('Signal connection got severed during reconnect')); } - this.regionUrlProvider?.resetAttempts(); + self.regionUrlProvider?.resetAttempts(); // reconnect success - this.emit(EngineEvent.Restarted); + self.emit(EngineEvent.Restarted); return ok(); - } catch (error) { + }); + + const restartResult = await restartResultAsync; + + if (restartResult.isErr()) { const nextRegionUrl = await this.regionUrlProvider?.getNextBestRegionUrl(); if (nextRegionUrl) { + this.log.info('retrying signal connection'); return this.restartConnection(nextRegionUrl); } else { // no more regions to try (or we're not on cloud) this.regionUrlProvider?.resetAttempts(); - throw error; + return err(restartResult.error); } } + return ok(restartResult.value); } - private async resumeConnection(reason?: ReconnectReason) { + private async resumeConnection( + reason?: ReconnectReason, + ): Promise> { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection - throw new UnexpectedConnectionState('could not reconnect, url or token not saved'); + return errAsync(new UnexpectedConnectionState('could not reconnect, url or token not saved')); } // trigger publisher reconnect if (!this.pcManager) { - throw new UnexpectedConnectionState('publisher and subscriber connections unset'); + return errAsync(new UnexpectedConnectionState('publisher and subscriber connections unset')); } this.log.info(`resuming signal connection, attempt ${this.reconnectAttempts}`, this.logContext); this.emit(EngineEvent.Resuming); this.setupSignalClientCallbacks(); + const reconnectResult = await this.client.reconnect( this.url, this.token, this.participantSid, reason, ); + if (reconnectResult.isErr()) { return err(reconnectResult.error); } @@ -1151,7 +1162,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // re-check signal connection state before setting engine as resumed if (this.client.currentState !== SignalConnectionState.CONNECTED) { - throw new SignalReconnectError('Signal connection got severed during reconnect'); + return err(new SignalReconnectError('Signal connection got severed during reconnect')); } this.client.setReconnected(); From 23754a2047b6b851964350e4ed4d087891414077 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 11:04:28 +0100 Subject: [PATCH 36/58] remove debug logs --- src/api/SignalClient.ts | 26 ++++++-------------------- src/api/WebSocketStream.ts | 1 - src/room/RTCEngine.ts | 2 +- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index c507f1bf71..b2af910f09 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -295,22 +295,17 @@ export class SignalClient { }); if (self.ws) { - await self.close(false); + await self.close( + false, + opts?.reconnectReason ? ReconnectReason[opts.reconnectReason] : undefined, + ); } const ws = new WebSocketStream(rtcUrl); self.ws = ws; - console.warn('set new ws on self with state', ws.readyState); - setInterval(() => { - if (ws === self.ws) { - console.warn('ws with connection state', ws.readyState, ws === self.ws); - } - }, 100); - const wsConnectionResult = withTimeout( ws.opened.andTee((connection) => { - console.warn('setting stream writer'); self.streamWriter = connection.writable.getWriter(); }), opts.websocketTimeout, @@ -345,11 +340,6 @@ export class SignalClient { self.handleWSError(error); }) .andThen((closeInfo) => { - if (ws === self.ws) { - console.warn('result websocket closed, should do a reconnect now', { - closeInfo, - }); - } if ( // we only log the warning here if the current ws connection is still the same, we don't care about closing of older ws connections that have been replaced ws === self.ws @@ -364,9 +354,8 @@ export class SignalClient { if (self.state == SignalConnectionState.CONNECTED) { self.handleOnClose(closeInfo.reason ?? 'Websocket closed unexpectedly'); } else { - console.warn( - 'ws closed unexpectedly in state', - SignalConnectionState[self.state], + self.log.warn( + `ws closed unexpectedly in state ${SignalConnectionState[self.state]}`, ); } } @@ -465,9 +454,7 @@ export class SignalClient { this.log.debug(`ignoring signal close as it's already in disconnecting state`); return; } - console.warn('queuing ws close'); const unlock = await this.closingLock.lock(); - console.warn('performing ws close'); try { this.clearPingInterval(); @@ -884,7 +871,6 @@ export class SignalClient { * @internal */ private handleSignalConnected(connection: WebSocketConnection, firstMessage?: SignalResponse) { - console.warn('signal back connected'); this.state = SignalConnectionState.CONNECTED; this.startPingInterval(); this.startReadingLoop(connection.readable.getReader(), firstMessage); diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index a5d1f2c4d8..dbd37dc6be 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -147,7 +147,6 @@ export class WebSocketStream { - console.warn('ws closed'); resolve({ closeCode: code, reason }); ws.removeEventListener('error', errorHandler); }; diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index ac7a788b7f..b6e8ca4c2b 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -39,7 +39,7 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; -import { type Result, ResultAsync, err, errAsync, ok, safeTry } from 'neverthrow'; +import { type Result, err, errAsync, ok, safeTry } from 'neverthrow'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; From 73e6df8c8bb64d8dcd40ee2072e47cf8598b47e4 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 11:34:15 +0100 Subject: [PATCH 37/58] timing fixes --- src/api/SignalClient.test.ts | 2 +- src/api/SignalClient.ts | 49 +++++++++++++++++++----------------- src/api/utils.ts | 1 - 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index 97205df97f..c971065edb 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -310,7 +310,7 @@ describe('SignalClient.connect', () => { await streamWriterReadyPromise; // Now abort the connection (after WS opens, before join response) - abortController.abort(new Error('User aborted connection')); + abortController.abort(); // joinPromise should return Err result const result = await joinPromise; diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index b2af910f09..34d4a417b9 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -304,31 +304,33 @@ export class SignalClient { const ws = new WebSocketStream(rtcUrl); self.ws = ws; - const wsConnectionResult = withTimeout( - ws.opened.andTee((connection) => { - self.streamWriter = connection.writable.getWriter(); - }), - opts.websocketTimeout, - ).mapErr(async (error) => { - // retrieve info about what error was causing the connection failure and enhance the returned error - if (self.state !== SignalConnectionState.CONNECTED) { - self.state = SignalConnectionState.DISCONNECTED; - const connectionError = await withAbort( - withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), - abortSignal, - ); - - const closeReason = `${error.reason}: ${error.message}`; - - self.close(undefined, closeReason); - if (connectionError.isErr()) { - return connectionError.error; + const wsConnectionResult = withTimeout(ws.opened, opts.websocketTimeout).mapErr( + async (error) => { + // retrieve info about what error was causing the connection failure and enhance the returned error + if (self.state !== SignalConnectionState.CONNECTED) { + self.state = SignalConnectionState.DISCONNECTED; + const connectionError = await withAbort( + withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), + abortSignal, + ); + + const closeReason = `${error.reason}: ${error.message}`; + + self.close(undefined, closeReason); + if (connectionError.isErr()) { + return connectionError.error; + } } - } - return error; - }); + return error; + }, + ); - const wsConnection = yield* withAbort(wsConnectionResult, abortSignal).orTee((error) => { + const wsConnection = yield* withAbort( + wsConnectionResult.andTee((connection) => { + self.streamWriter = connection.writable.getWriter(); + }), + abortSignal, + ).orTee((error) => { self.close(undefined, error.message); }); @@ -372,6 +374,7 @@ export class SignalClient { withTimeout(firstMessageOrClose, 5_000), abortSignal, ).orTee((error) => { + console.warn('signal connection aborted'); if (error.reason === ConnectionErrorReason.Cancelled) { self .sendLeave() diff --git a/src/api/utils.ts b/src/api/utils.ts index 16bb57516c..d5766d522b 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,7 +1,6 @@ import type { Mutex } from '@livekit/mutex'; import { SignalResponse } from '@livekit/protocol'; import { Result, ResultAsync, errAsync } from 'neverthrow'; -import type { RegionUrlProvider } from '../room/RegionUrlProvider'; import { ConnectionError } from '../room/errors'; import { toHttpUrl, toWebsocketUrl } from '../room/utils'; From efc969afd1c8943c2d77b3283173f91164d95b74 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 14:47:33 +0100 Subject: [PATCH 38/58] address comments --- src/room/RTCEngine.ts | 3 +-- src/room/Room.ts | 5 +++++ src/room/errors.ts | 18 ------------------ 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index b6e8ca4c2b..5dc5528bf9 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -256,8 +256,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit token: string, opts: SignalOptions, abortSignal?: AbortSignal, - // TODO: be more explicit about error types - ): Promise> { + ): Promise> { this.url = url; this.token = token; this.signalOpts = opts; diff --git a/src/room/Room.ts b/src/room/Room.ts index cbaa9679e6..5d6a725913 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1917,6 +1917,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) }); if (byteLength(response) > MAX_PAYLOAD_BYTES) { responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); + this.log.warn(`RPC Response payload too large for ${method}`, this.logContext); } else { responsePayload = response; } @@ -1924,6 +1925,10 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (error instanceof RpcError) { responseError = error; } else { + this.log.warn( + `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, + { ...this.logContext, error }, + ); responseError = RpcError.builtIn('APPLICATION_ERROR'); } } diff --git a/src/room/errors.ts b/src/room/errors.ts index b77da726c6..6e15af9001 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -222,24 +222,6 @@ export class SignalRequestError extends LivekitError { } } -// export class TimeoutError extends LivekitError { -// readonly type = 'timeout'; - -// constructor(message: string = 'TimeoutError') { -// super(17, message); -// this.name = 'TimeoutError'; -// } -// } - -// export class AbortError extends LivekitError { -// readonly type = 'abort'; - -// constructor(message: string = 'AbortError') { -// super(18, message); -// this.name = 'AbortError'; -// } -// } - // NOTE: matches with https://github.com/livekit/client-sdk-swift/blob/f37bbd260d61e165084962db822c79f995f1a113/Sources/LiveKit/DataStream/StreamError.swift#L17 export enum DataStreamErrorReason { // Unable to open a stream with the same ID more than once. From 0b8a6dc019e897b8963d7e6d74bba630eb7100e2 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 14:51:49 +0100 Subject: [PATCH 39/58] fix import --- src/room/RegionUrlProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/RegionUrlProvider.ts b/src/room/RegionUrlProvider.ts index a9ce2024a4..a3d4f6fe2f 100644 --- a/src/room/RegionUrlProvider.ts +++ b/src/room/RegionUrlProvider.ts @@ -1,7 +1,7 @@ import { Mutex } from '@livekit/mutex'; import type { RegionInfo, RegionSettings } from '@livekit/protocol'; import log from '../logger'; -import { ConnectionError } from './errors'; +import { ConnectionError, ConnectionErrorReason } from './errors'; import { extractMaxAgeFromRequestHeaders, isCloud } from './utils'; export const DEFAULT_MAX_AGE_MS = 5_000; From 3f4273e87a269bc9cdc4209baa394ed0057d5d1b Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 14:57:58 +0100 Subject: [PATCH 40/58] more narrow error types --- src/api/SignalClient.ts | 2 +- src/room/RTCEngine.ts | 2 +- src/room/errors.ts | 9 ++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 34d4a417b9..5c51a7dd82 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -980,7 +980,7 @@ export class SignalClient { return err( ConnectionError.internal( `Encountered unknown websocket error during connection: ${reason}`, - `ResponseStatus: ${resp.status}`, + { status: resp.status, statusTest: resp.statusText }, ), ); } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index ca7d7c336a..e6b232b743 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1204,7 +1204,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } catch (e: any) { // TODO do we need a `failed` state here for the PC? this.pcState = PCState.Disconnected; - throw ConnectionError.internal(`could not establish PC connection, ${e.message}`); + throw ConnectionError.internal(`could not establish PC connection: ${e.message}`); } } diff --git a/src/room/errors.ts b/src/room/errors.ts index 6e15af9001..455859bba5 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -37,7 +37,7 @@ type NotAllowed = { type InternalError = { reason: ConnectionErrorReason.InternalError; status: never; - context?: unknown; + context?: { status?: number; statusText?: string }; }; type ConnectionTimeout = { @@ -61,7 +61,7 @@ type Cancelled = { type ServerUnreachable = { reason: ConnectionErrorReason.ServerUnreachable; status?: number; - context?: unknown; + context?: never; }; type WebSocket = { @@ -127,7 +127,7 @@ export class ConnectionError< ); } - static internal(message: string, context?: unknown) { + static internal(message: string, context?: { status?: number; statusText?: string }) { return new ConnectionError( message, ConnectionErrorReason.InternalError, @@ -140,12 +140,11 @@ export class ConnectionError< return new ConnectionError(message, ConnectionErrorReason.Cancelled); } - static serverUnreachable(message: string, status?: number, context?: unknown) { + static serverUnreachable(message: string, status?: number) { return new ConnectionError( message, ConnectionErrorReason.ServerUnreachable, status, - context, ); } From 6ee7b5867b629512d2b1c02de886c81f21625fb2 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 14:59:54 +0100 Subject: [PATCH 41/58] Fix typo --- src/api/SignalClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 5c51a7dd82..625c403ec0 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -980,7 +980,7 @@ export class SignalClient { return err( ConnectionError.internal( `Encountered unknown websocket error during connection: ${reason}`, - { status: resp.status, statusTest: resp.statusText }, + { status: resp.status, statusText: resp.statusText }, ), ); } From 29ed2dea054a4e84f928901593a99e3bb2775951 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 18 Nov 2025 17:44:04 +0100 Subject: [PATCH 42/58] fix reconnect behavior when internet goes away for longer time --- src/api/SignalClient.ts | 10 +---- src/room/RTCEngine.ts | 27 ++++++++---- src/room/RegionUrlProvider.ts | 52 ++++++++++++++++-------- src/room/participant/LocalParticipant.ts | 2 +- src/room/track/utils.ts | 1 - 5 files changed, 58 insertions(+), 34 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index dc5c775651..af4ac90f31 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -256,7 +256,6 @@ export class SignalClient { ConnectionError.internal('attempted to reconnect without signal options being set'), ); } - console.warn('reconnecting signal'); this.state = SignalConnectionState.RECONNECTING; // clear ping interval and restart it once reconnected this.clearPingInterval(); @@ -375,7 +374,7 @@ export class SignalClient { withTimeout(firstMessageOrClose, 5_000), abortSignal, ).orTee((error) => { - console.warn('signal connection aborted'); + ('signal connection aborted'); if (error.reason === ConnectionErrorReason.Cancelled) { self .sendLeave() @@ -954,12 +953,7 @@ export class SignalClient { ), ); } - console.warn('first message', { - msg: firstSignalResponse, - isReconnect, - state: this.state, - stateName: SignalConnectionState[this.state], - }); + return err(ConnectionError.internal('Unexpected first message')); } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 2a428a1f26..4b34c8d58d 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -303,6 +303,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.registerOnLineListener(); this.clientConfiguration = joinResponse.clientConfiguration; this.emit(EngineEvent.SignalConnected, joinResponse); + this.joinAttempts = 0; return ok(joinResponse); } @@ -1015,7 +1016,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit recoverable = false; } else if (!(error instanceof SignalReconnectError)) { // cannot resume - console.warn('cannot resume, error is', error); this.fullReconnectOnNext = true; } @@ -1050,7 +1050,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit regionUrl?: string, ): Promise> { const self = this; - const restartResultAsync = safeTry(async function* () { + const restartResultAsync = safeTry< + void, + SimulatedError | SignalReconnectError | UnexpectedConnectionState + >(async function* () { if (!self.url || !self.token) { // permanent failure, don't attempt reconnection return err(new UnexpectedConnectionState('could not reconnect, url or token not saved')); @@ -1072,14 +1075,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ); return err(new SignalReconnectError()); } + // in case a regionUrl is passed, the region URL takes precedence const joinResult = await self.join(regionUrl ?? self.url, self.token, self.signalOpts); + if (joinResult.isErr()) { const error = joinResult.error; if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.NotAllowed) { return err(new UnexpectedConnectionState('could not reconnect, token might be expired')); } - return err(new SignalReconnectError()); + return err(new SignalReconnectError(error.message)); } if (self.shouldFailNext) { @@ -1106,14 +1111,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const restartResult = await restartResultAsync; if (restartResult.isErr()) { - const nextRegionUrl = await this.regionUrlProvider?.getNextBestRegionUrl(); + this.log.info('trying to get the next region'); + if (!this.regionUrlProvider) { + return restartResult; + } + const nextRegionUrlResult = await this.regionUrlProvider.getNextBestRegionUrl(); + if (nextRegionUrlResult.isErr()) { + return err(nextRegionUrlResult.error); + } + const nextRegionUrl = nextRegionUrlResult.value; if (nextRegionUrl) { - this.log.info('retrying signal connection'); + this.log.info('retrying signal connection with region'); + this.joinAttempts = 0; return this.restartConnection(nextRegionUrl); } else { // no more regions to try (or we're not on cloud) this.regionUrlProvider?.resetAttempts(); - return err(restartResult.error); + return restartResult; } } return ok(restartResult.value); @@ -1507,7 +1521,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const negotiationWithErrorHandler = closedOrNegotiate.orTee((e) => { if (e instanceof NegotiationError) { - console.warn('cannot resume after negotiation error, error is', e); this.fullReconnectOnNext = true; } this.handleDisconnect('negotiation', ReconnectReason.RR_UNKNOWN); diff --git a/src/room/RegionUrlProvider.ts b/src/room/RegionUrlProvider.ts index a3d4f6fe2f..00b01e8ce5 100644 --- a/src/room/RegionUrlProvider.ts +++ b/src/room/RegionUrlProvider.ts @@ -1,5 +1,6 @@ import { Mutex } from '@livekit/mutex'; import type { RegionInfo, RegionSettings } from '@livekit/protocol'; +import { ResultAsync, errAsync, okAsync, safeTry } from 'neverthrow'; import log from '../logger'; import { ConnectionError, ConnectionErrorReason } from './errors'; import { extractMaxAgeFromRequestHeaders, isCloud } from './utils'; @@ -204,29 +205,46 @@ export class RegionUrlProvider { return RegionUrlProvider.fetchRegionSettings(this.serverUrl, this.token, abortSignal); } - async getNextBestRegionUrl(abortSignal?: AbortSignal) { + getNextBestRegionUrl(abortSignal?: AbortSignal): ResultAsync { if (!this.isCloud()) { - throw Error('region availability is only supported for LiveKit Cloud domains'); + return errAsync( + ConnectionError.internal('region availability is only supported for LiveKit Cloud domains'), + ); } let cachedSettings = RegionUrlProvider.cache.get(this.serverUrl.hostname); - if (!cachedSettings || Date.now() - cachedSettings.updatedAtInMs > cachedSettings.maxAgeInMs) { - cachedSettings = await this.fetchRegionSettings(abortSignal); - RegionUrlProvider.updateCachedRegionSettings(this.serverUrl, this.token, cachedSettings); - } + const self = this; + return safeTry(async function* () { + if ( + !cachedSettings || + Date.now() - cachedSettings.updatedAtInMs > cachedSettings.maxAgeInMs + ) { + const settingsResult = ResultAsync.fromPromise( + self.fetchRegionSettings(abortSignal), + (e) => e as ConnectionError, + ); - const regionsLeft = cachedSettings.regionSettings.regions.filter( - (region) => !this.attemptedRegions.find((attempted) => attempted.url === region.url), - ); - if (regionsLeft.length > 0) { - const nextRegion = regionsLeft[0]; - this.attemptedRegions.push(nextRegion); - log.debug(`next region: ${nextRegion.region}`); - return nextRegion.url; - } else { - return null; - } + cachedSettings = yield* settingsResult; + RegionUrlProvider.updateCachedRegionSettings(self.serverUrl, self.token, cachedSettings); + } + + if (!cachedSettings) { + return okAsync(null); + } + + const regionsLeft = cachedSettings.regionSettings.regions.filter( + (region) => !self.attemptedRegions.find((attempted) => attempted.url === region.url), + ); + if (regionsLeft.length > 0) { + const nextRegion = regionsLeft[0]; + self.attemptedRegions.push(nextRegion); + log.debug(`next region: ${nextRegion.region}`); + return okAsync(nextRegion.url); + } else { + return okAsync(null); + } + }); } resetAttempts() { diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index b479960ec8..bff33e36f9 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1854,7 +1854,7 @@ export default class LocalParticipant extends Participant { resolve: (responsePayload: string | null, responseError: RpcError | null) => { clearTimeout(responseTimeoutId); if (this.pendingAcks.has(id)) { - console.warn('RPC response received before ack', id); + this.log.warn('RPC response received before ack', id); this.pendingAcks.delete(id); clearTimeout(ackTimeoutId); } diff --git a/src/room/track/utils.ts b/src/room/track/utils.ts index e4974e76eb..c67049c09a 100644 --- a/src/room/track/utils.ts +++ b/src/room/track/utils.ts @@ -146,7 +146,6 @@ export function getNewAudioContext(): AudioContext | void { await audioContext.resume(); } } catch (e) { - console.warn('Error trying to auto-resume audio context', e); } finally { window.document.body?.removeEventListener('click', handleResume); } From d356a6c7cf0cfa460d24b8897cf8dc8b86486c81 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 10:10:08 +0100 Subject: [PATCH 43/58] address comments --- src/api/utils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/api/utils.ts b/src/api/utils.ts index d5766d522b..a8f156f08c 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -80,10 +80,9 @@ export function withAbort( const abortResult = ResultAsync.fromPromise( new Promise((_, reject) => { const onAbortHandler = () => { - signal?.removeEventListener('abort', onAbortHandler); reject(ConnectionError.cancelled('AbortSignal invoked')); }; - signal?.addEventListener('abort', onAbortHandler); + signal?.addEventListener('abort', onAbortHandler, { once: true }); }), (e) => e as ReturnType, ); @@ -129,7 +128,6 @@ export function withFinally( }, ); } catch (error) { - await onFinally(); throw error as Error; } finally { await onFinally(); From 1faee940901c37b9cf8d818d69a2b9f12a11defd Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 10:29:59 +0100 Subject: [PATCH 44/58] remove debug --- src/api/SignalClient.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index af4ac90f31..1300cf28cd 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -374,7 +374,6 @@ export class SignalClient { withTimeout(firstMessageOrClose, 5_000), abortSignal, ).orTee((error) => { - ('signal connection aborted'); if (error.reason === ConnectionErrorReason.Cancelled) { self .sendLeave() From c5fe12636b5569dc7354977fceba0f5d0d517e0c Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 10:33:32 +0100 Subject: [PATCH 45/58] wording --- src/room/RTCEngine.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 4b34c8d58d..47d046378c 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1111,7 +1111,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const restartResult = await restartResultAsync; if (restartResult.isErr()) { - this.log.info('trying to get the next region'); if (!this.regionUrlProvider) { return restartResult; } @@ -1121,7 +1120,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } const nextRegionUrl = nextRegionUrlResult.value; if (nextRegionUrl) { - this.log.info('retrying signal connection with region'); + this.log.info('retrying signal connection with a different region'); this.joinAttempts = 0; return this.restartConnection(nextRegionUrl); } else { From af03e361ed9be25c3a71a94632dc7264d1392c5d Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 10:46:19 +0100 Subject: [PATCH 46/58] change workflow trigger --- .github/workflows/size-limit.yaml | 3 --- .github/workflows/test.yaml | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/size-limit.yaml b/.github/workflows/size-limit.yaml index fd564cbacc..f5bbc58758 100644 --- a/.github/workflows/size-limit.yaml +++ b/.github/workflows/size-limit.yaml @@ -1,9 +1,6 @@ name: 'size' on: pull_request: - branches: - - main - workflow_dispatch: jobs: package-size: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 891e2ef335..e0d62ce35e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,7 @@ on: branches: [ main ] pull_request: branches: [ main ] + workflow_dispatch: jobs: test: From 0523e35c018969e74467387af259a6f2c2152916 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 10:55:53 +0100 Subject: [PATCH 47/58] unwrap regionurl results --- src/room/Room.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index c374929e2c..131e38a466 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -31,6 +31,7 @@ import { protoInt64, } from '@livekit/protocol'; import { EventEmitter } from 'events'; +import { ok } from 'neverthrow'; import type TypedEmitter from 'typed-emitter'; import 'webrtc-adapter'; import { EncryptionEvent } from '../e2ee'; @@ -601,7 +602,12 @@ class Room extends (EventEmitter as new () => TypedEmitter) try { if (isCloud(new URL(url)) && token) { this.regionUrlProvider = new RegionUrlProvider(url, token); - const regionUrl = await this.regionUrlProvider.getNextBestRegionUrl(); + const regionUrlResult = await this.regionUrlProvider.getNextBestRegionUrl(); + if (regionUrlResult.isErr()) { + // TODO continue propagation here once we have a `safeFetch` that returns a ResultAsync + throw regionUrlResult.error; + } + const regionUrl = regionUrlResult.value; // we will not replace the regionUrl if an attempt had already started // to avoid overriding regionUrl after a new connection attempt had started if (regionUrl && this.state === ConnectionState.Disconnected) { @@ -698,13 +704,12 @@ class Room extends (EventEmitter as new () => TypedEmitter) error.reason !== ConnectionErrorReason.Cancelled && error.reason !== ConnectionErrorReason.NotAllowed ) { - let nextUrl: string | null = null; - try { - this.log.debug('Fetching next region'); - nextUrl = await this.regionUrlProvider.getNextBestRegionUrl( - this.abortController?.signal, - ); - } catch (regionFetchError) { + this.log.debug('Fetching next region'); + const nextUrlResult = await this.regionUrlProvider.getNextBestRegionUrl( + this.abortController?.signal, + ); + if (nextUrlResult.isErr()) { + const regionFetchError = nextUrlResult.error; if ( regionFetchError instanceof ConnectionError && (regionFetchError.status === 401 || @@ -715,6 +720,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) return; } } + const nextUrl = nextUrlResult.orElse(() => ok(null))._unsafeUnwrap(); if ( // making sure we only register failed attempts on things we actually care about [ From 3ce483ca88bab6762ed3efae6a25593edda682e2 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 11:00:42 +0100 Subject: [PATCH 48/58] adopt result pattern for url provider tests --- src/room/RegionUrlProvider.test.ts | 90 +++++++++++++++++++----------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/src/room/RegionUrlProvider.test.ts b/src/room/RegionUrlProvider.test.ts index faa37868d2..42f3b708dc 100644 --- a/src/room/RegionUrlProvider.test.ts +++ b/src/room/RegionUrlProvider.test.ts @@ -86,13 +86,15 @@ describe('RegionUrlProvider', () => { ); // Get first region - const region1 = await provider.getNextBestRegionUrl(); - expect(region1).toBe('wss://us-west.livekit.cloud'); + const result1 = await provider.getNextBestRegionUrl(); + expect(result1.isOk()).toBe(true); + expect(result1._unsafeUnwrap()).toBe('wss://us-west.livekit.cloud'); // Reset and verify we can get the first region again provider.resetAttempts(); - const region2 = await provider.getNextBestRegionUrl(); - expect(region2).toBe('wss://us-west.livekit.cloud'); + const result2 = await provider.getNextBestRegionUrl(); + expect(result2.isOk()).toBe(true); + expect(result2._unsafeUnwrap()).toBe('wss://us-west.livekit.cloud'); }); }); @@ -249,10 +251,12 @@ describe('RegionUrlProvider', () => { }); describe('getNextBestRegionUrl', () => { - it('throws error for non-cloud domains', async () => { + it('returns error for non-cloud domains', async () => { const provider = new RegionUrlProvider('wss://self-hosted.example.com', 'token'); - await expect(provider.getNextBestRegionUrl()).rejects.toThrow( + const result = await provider.getNextBestRegionUrl(); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().message).toContain( 'region availability is only supported for LiveKit Cloud domains', ); }); @@ -268,8 +272,9 @@ describe('RegionUrlProvider', () => { createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); - const region = await provider.getNextBestRegionUrl(); - expect(region).toBe('wss://us-west.livekit.cloud'); + const result = await provider.getNextBestRegionUrl(); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe('wss://us-west.livekit.cloud'); }); it('returns subsequent regions on repeated calls', async () => { @@ -284,13 +289,16 @@ describe('RegionUrlProvider', () => { createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); - const region1 = await provider.getNextBestRegionUrl(); - const region2 = await provider.getNextBestRegionUrl(); - const region3 = await provider.getNextBestRegionUrl(); + const result1 = await provider.getNextBestRegionUrl(); + const result2 = await provider.getNextBestRegionUrl(); + const result3 = await provider.getNextBestRegionUrl(); - expect(region1).toBe('wss://us-west.livekit.cloud'); - expect(region2).toBe('wss://us-east.livekit.cloud'); - expect(region3).toBe('wss://eu-central.livekit.cloud'); + expect(result1.isOk()).toBe(true); + expect(result1._unsafeUnwrap()).toBe('wss://us-west.livekit.cloud'); + expect(result2.isOk()).toBe(true); + expect(result2._unsafeUnwrap()).toBe('wss://us-east.livekit.cloud'); + expect(result3.isOk()).toBe(true); + expect(result3._unsafeUnwrap()).toBe('wss://eu-central.livekit.cloud'); }); it('returns null when all regions exhausted', async () => { @@ -304,9 +312,10 @@ describe('RegionUrlProvider', () => { ); await provider.getNextBestRegionUrl(); - const region = await provider.getNextBestRegionUrl(); + const result = await provider.getNextBestRegionUrl(); - expect(region).toBeNull(); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBeNull(); }); it('uses cached settings when available and fresh', async () => { @@ -383,9 +392,13 @@ describe('RegionUrlProvider', () => { createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); - const region1 = await provider.getNextBestRegionUrl(); - const region2 = await provider.getNextBestRegionUrl(); + const result1 = await provider.getNextBestRegionUrl(); + const result2 = await provider.getNextBestRegionUrl(); + expect(result1.isOk()).toBe(true); + expect(result2.isOk()).toBe(true); + const region1 = result1._unsafeUnwrap(); + const region2 = result2._unsafeUnwrap(); expect(region1).toBe('wss://us-west.livekit.cloud'); expect(region2).toBe('wss://us-east.livekit.cloud'); expect(region1).not.toBe(region2); @@ -403,13 +416,15 @@ describe('RegionUrlProvider', () => { // Exhaust regions await provider.getNextBestRegionUrl(); - let region = await provider.getNextBestRegionUrl(); - expect(region).toBeNull(); + let result = await provider.getNextBestRegionUrl(); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBeNull(); // Reset and try again provider.resetAttempts(); - region = await provider.getNextBestRegionUrl(); - expect(region).toBe('wss://us-west.livekit.cloud'); + result = await provider.getNextBestRegionUrl(); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe('wss://us-west.livekit.cloud'); }); it('respects abort signal', async () => { @@ -611,8 +626,9 @@ describe('RegionUrlProvider', () => { }); // Should use cached settings without fetching - const region = await provider.getNextBestRegionUrl(); - expect(region).toBe('wss://us-west.livekit.cloud'); + const result = await provider.getNextBestRegionUrl(); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe('wss://us-west.livekit.cloud'); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -696,8 +712,9 @@ describe('RegionUrlProvider', () => { createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); - const region = await provider.getNextBestRegionUrl(); - expect(region).toBeNull(); + const result = await provider.getNextBestRegionUrl(); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBeNull(); }); it('handles malformed region settings response', async () => { @@ -747,14 +764,16 @@ describe('RegionUrlProvider', () => { ); // Make concurrent calls - const [region1, region2] = await Promise.all([ + const [result1, result2] = await Promise.all([ provider.getNextBestRegionUrl(), provider.getNextBestRegionUrl(), ]); // Both should return regions (may be same or different depending on timing) - expect(region1).toBeTruthy(); - expect(region2).toBeTruthy(); + expect(result1.isOk()).toBe(true); + expect(result2.isOk()).toBe(true); + expect(result1._unsafeUnwrap()).toBeTruthy(); + expect(result2._unsafeUnwrap()).toBeTruthy(); }); it('preserves cache when one instance fails to fetch', async () => { @@ -777,7 +796,8 @@ describe('RegionUrlProvider', () => { // Second provider tries to fetch but fails const provider2 = new RegionUrlProvider('wss://test.livekit.cloud', 'token2'); - await expect(provider2.getNextBestRegionUrl()).rejects.toThrow(); + const result = await provider2.getNextBestRegionUrl(); + expect(result.isErr()).toBe(true); // Cache should still be accessible by a third instance if still valid expect(fetchMock).toHaveBeenCalledTimes(2); @@ -826,12 +846,14 @@ describe('RegionUrlProvider', () => { createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); - const region1 = await provider.getNextBestRegionUrl(); - const region2 = await provider.getNextBestRegionUrl(); + const result1 = await provider.getNextBestRegionUrl(); + const result2 = await provider.getNextBestRegionUrl(); // First region should be returned, second should be null since it has the same URL - expect(region1).toBe('wss://us-west.livekit.cloud'); - expect(region2).toBeNull(); // Filtered out because same URL was already attempted + expect(result1.isOk()).toBe(true); + expect(result1._unsafeUnwrap()).toBe('wss://us-west.livekit.cloud'); + expect(result2.isOk()).toBe(true); + expect(result2._unsafeUnwrap()).toBeNull(); // Filtered out because same URL was already attempted }); }); From b39e4e65a7d7fd3a78a2b03fbcb2f5cb9f4027f5 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 11:03:41 +0100 Subject: [PATCH 49/58] fix conneciton check --- src/connectionHelper/checks/cloudRegion.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/connectionHelper/checks/cloudRegion.ts b/src/connectionHelper/checks/cloudRegion.ts index 2f935bb5e3..5203a801e6 100644 --- a/src/connectionHelper/checks/cloudRegion.ts +++ b/src/connectionHelper/checks/cloudRegion.ts @@ -27,7 +27,12 @@ export class CloudRegionCheck extends Checker { const regionStats: RegionStats[] = []; const seenUrls: Set = new Set(); for (let i = 0; i < 3; i++) { - const regionUrl = await regionProvider.getNextBestRegionUrl(); + const regionUrlResult = await regionProvider.getNextBestRegionUrl(); + if (regionUrlResult.isErr()) { + console.error(regionUrlResult.error); + return; + } + const regionUrl = regionUrlResult.value; if (!regionUrl) { break; } From d2e78b86fd1da12438a95144c7c58dc370caaf23 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 11:13:24 +0100 Subject: [PATCH 50/58] fix unsafe usage --- src/connectionHelper/checks/turn.ts | 7 +++++-- src/room/Room.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/connectionHelper/checks/turn.ts b/src/connectionHelper/checks/turn.ts index 4eabb6fa22..69dff58030 100644 --- a/src/connectionHelper/checks/turn.ts +++ b/src/connectionHelper/checks/turn.ts @@ -16,8 +16,11 @@ export class TURNCheck extends Checker { singlePeerConnection: false, }); - // TODO fix unsafe usage - const joinRes = joinResult._unsafeUnwrap(); + if (joinResult.isErr()) { + throw joinResult.error; + } + + const joinRes = joinResult.value; let hasTLS = false; let hasTURN = false; diff --git a/src/room/Room.ts b/src/room/Room.ts index 131e38a466..7ccce41076 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -720,7 +720,11 @@ class Room extends (EventEmitter as new () => TypedEmitter) return; } } - const nextUrl = nextUrlResult.orElse(() => ok(null))._unsafeUnwrap(); + const nextUrl = nextUrlResult.match( + (res) => res, + () => null, + ); + if ( // making sure we only register failed attempts on things we actually care about [ From 5c7c3c9d3d178a452f5dab5b70682e7b032a6e70 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 19 Nov 2025 11:28:54 +0100 Subject: [PATCH 51/58] fix import --- src/room/Room.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 7ccce41076..f13382e75a 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -31,7 +31,6 @@ import { protoInt64, } from '@livekit/protocol'; import { EventEmitter } from 'events'; -import { ok } from 'neverthrow'; import type TypedEmitter from 'typed-emitter'; import 'webrtc-adapter'; import { EncryptionEvent } from '../e2ee'; From d8aa2b0ca65de5f5446bf338bc025dea2abe8916 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 21 Nov 2025 11:10:41 +0100 Subject: [PATCH 52/58] address comments and fix linting --- eslint.config.mjs | 12 ++++++++++++ package.json | 1 + pnpm-lock.yaml | 23 +++++++++++++++++++---- src/api/SignalClient.test.ts | 20 ++++++++++---------- src/api/SignalClient.ts | 2 +- src/api/WebSocketStream.ts | 4 ++-- src/api/utils.ts | 8 ++++---- 7 files changed, 49 insertions(+), 21 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 246c84842a..5c061396e6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,7 @@ import js from '@eslint/js'; import { configs, plugins, rules } from 'eslint-config-airbnb-extended'; import { rules as prettierConfigRules } from 'eslint-config-prettier'; +import neverthrowMustUse from 'eslint-plugin-neverthrow-must-use'; import prettierPlugin from 'eslint-plugin-prettier'; const strictness = 'off'; @@ -31,6 +32,15 @@ const typescriptConfig = [ rules.typescript.typescriptEslintStrict, ]; +const neverthrowConfig = [ + { + name: 'neverthrow-must-use', + plugins: { + 'neverthrow-must-use': neverthrowMustUse, + }, + }, +]; + const prettierConfig = [ // Prettier Plugin { @@ -56,6 +66,7 @@ export default [ ...typescriptConfig, // Prettier Config ...prettierConfig, + ...neverthrowConfig, { languageOptions: { parserOptions: { @@ -158,6 +169,7 @@ export default [ 'one-var': strictness, 'no-multi-assign': strictness, 'new-cap': strictness, + 'require-yield': strictness, radix: strictness, eqeqeq: strictness, diff --git a/package.json b/package.json index 15b660f910..b089fe9274 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "eslint-config-prettier": "10.1.8", "eslint-plugin-compat": "^6.0.2", "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-neverthrow-must-use": "^0.1.2", "eslint-plugin-prettier": "^5.5.4", "gh-pages": "6.3.0", "happy-dom": "^17.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d57cbfb88..ce99d24059 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: eslint-plugin-import-x: specifier: ^4.16.1 version: 4.16.1(@typescript-eslint/utils@8.47.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.4.2)) + eslint-plugin-neverthrow-must-use: + specifier: ^0.1.2 + version: 0.1.2(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.39.1(jiti@2.4.2)) eslint-plugin-prettier: specifier: ^5.5.4 version: 5.5.4(@types/eslint@8.44.7)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.4.2)))(eslint@9.39.1(jiti@2.4.2))(prettier@3.6.2) @@ -2273,6 +2276,13 @@ packages: peerDependencies: eslint: '>=8.23.0' + eslint-plugin-neverthrow-must-use@0.1.2: + resolution: {integrity: sha512-Wt/u1wjnH8rWtbc8zqTK5yOcB79zVlCCWXi6ChJNer5ACkqldNQ6/+RKVUErACbv0Oex9aqaKYoTd0OqLe4o3Q==} + engines: {node: '>=16'} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 + eslint: ^9.0.0 + eslint-plugin-prettier@5.5.4: resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3631,8 +3641,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20251120: - resolution: {integrity: sha512-dkvZw2/09r7JIltGCeubJXLYE7+NapbKj68BtGtm47TiwjyKxTDTG2nWZu8Gpopzi0ub9bNVn0rEgh5CgOlE4w==} + typescript@6.0.0-dev.20251121: + resolution: {integrity: sha512-TrGhGS4hOAKgwizhMuH/3pbTNNBMCpxRA7ia8Lrv4HRMOAOzI5lWhP5uoKRDmmaF3pUVe90MBYjSieM498zUqQ==} engines: {node: '>=14.17'} hasBin: true @@ -6030,7 +6040,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20251120 + typescript: 6.0.0-dev.20251121 dunder-proto@1.0.1: dependencies: @@ -6338,6 +6348,11 @@ snapshots: - typescript optional: true + eslint-plugin-neverthrow-must-use@0.1.2(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.39.1(jiti@2.4.2)): + dependencies: + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.39.1(jiti@2.4.2) + eslint-plugin-prettier@5.5.4(@types/eslint@8.44.7)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.4.2)))(eslint@9.39.1(jiti@2.4.2))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.4.2) @@ -7800,7 +7815,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20251120: {} + typescript@6.0.0-dev.20251121: {} uc.micro@2.1.0: {} diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index c971065edb..2037fdb119 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -66,18 +66,18 @@ interface MockWebSocketStreamOptions { function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) { const { connection, - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result opened = connection ? ResultAsync.fromPromise(Promise.resolve(connection), (error) => ({ type: 'connection' as const, error: error as Event, })) - : // eslint-disable-next-line neverthrow/must-use-result + : // eslint-disable-next-line neverthrow-must-use/must-use-result ResultAsync.fromPromise(new Promise(() => {}), (error) => ({ type: 'connection' as const, error: error as Event, })), - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result closed = ResultAsync.fromPromise(new Promise(() => {}), (error) => error as WebSocketError), readyState = 1, } = options; @@ -208,12 +208,12 @@ describe('SignalClient.connect', () => { // Simulate abort setTimeout(() => abortController.abort(new Error('User aborted connection')), 50); - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result const opened = ResultAsync.fromPromise(new Promise(() => {}), (error) => ({ type: 'connection' as const, error: error as Event, })); - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result const closed = ResultAsync.fromPromise( new Promise(() => {}), (error) => error as WebSocketError, @@ -278,12 +278,12 @@ describe('SignalClient.connect', () => { }; vi.mocked(WebSocketStream).mockImplementation(() => { - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result const opened = ResultAsync.fromPromise(Promise.resolve(mockConnection), (error) => ({ type: 'connection' as const, error: error as Event, })); - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result const closed = ResultAsync.fromPromise( new Promise(() => {}), (error) => error as WebSocketError, @@ -338,7 +338,7 @@ describe('SignalClient.connect', () => { describe('Failure Case - WebSocket Connection Errors', () => { it('should reject with NotAllowed error for 4xx HTTP status', async () => { - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result const opened = ResultAsync.fromPromise( Promise.reject(ConnectionError.websocket('Connection failed')), (error) => error as WebSocketError, @@ -365,7 +365,7 @@ describe('SignalClient.connect', () => { }); it('should reject with ServerUnreachable when fetch fails', async () => { - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result const opened = ResultAsync.fromPromise( Promise.reject(ConnectionError.websocket('Connection failed')), (error) => error as WebSocketError, @@ -389,7 +389,7 @@ describe('SignalClient.connect', () => { it('should handle WebsocketError from WebSocket rejection', async () => { const customError = ConnectionError.websocket('Custom error'); - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result const opened = ResultAsync.fromPromise( Promise.reject(customError), (error) => error as WebSocketError, diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 625c403ec0..24a54b6272 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -337,7 +337,7 @@ export class SignalClient { const firstMessageOrClose = raceResults([ self.processInitialSignalMessage(wsConnection), // Return the close promise as error if it resolves first - ws!.closed + ws.closed .orTee((error) => { self.handleWSError(error); }) diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index dbd37dc6be..6e25c223f7 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -55,7 +55,7 @@ export class WebSocketStream ws.close(code, reason); - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result this.opened = ResultAsync.fromPromise, WebSocketError>( new Promise((resolve, r) => { const reject = (err: WebSocketError) => r(err); @@ -111,7 +111,7 @@ export class WebSocketStream error as WebSocketError, ); - // eslint-disable-next-line neverthrow/must-use-result + // eslint-disable-next-line neverthrow-must-use/must-use-result this.closed = ResultAsync.fromPromise( new Promise((resolve, r) => { const reject = (err: WebSocketError) => r(err); diff --git a/src/api/utils.ts b/src/api/utils.ts index b80f867e81..74666588f3 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -160,11 +160,11 @@ export function raceResults[]>( T[number] extends ResultAsync ? V : never, T[number] extends ResultAsync ? E : never > { - type V = T[number] extends ResultAsync ? Value : never; - type E = T[number] extends ResultAsync ? Err : never; + type Value = T[number] extends ResultAsync ? V : never; + type Err = T[number] extends ResultAsync ? E : never; const settledPromises = values.map( - (ra): PromiseLike => + (ra): PromiseLike => ra.then((res) => res.match( (v) => Promise.resolve(v), @@ -173,7 +173,7 @@ export function raceResults[]>( ), ); - return ResultAsync.fromPromise(Promise.race(settledPromises), (e) => e as E); + return ResultAsync.fromPromise(Promise.race(settledPromises), (e) => e as Err); } export type ResultAsyncLike = ResultAsync | Promise>; From 2953a835e3a9d646646b4205a0876d67e024d2d4 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 21 Nov 2025 11:33:27 +0100 Subject: [PATCH 53/58] better test cases --- src/api/SignalClient.test.ts | 40 ++++++++++++++++++++++++++++++------ src/api/SignalClient.ts | 12 +++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index 2037fdb119..ffec63ce39 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -386,7 +386,7 @@ describe('SignalClient.connect', () => { expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.ServerUnreachable); }); - it('should handle WebsocketError from WebSocket rejection', async () => { + it('should handle WebsocketError from WebSocket rejection as unreachable if server is not reachable', async () => { const customError = ConnectionError.websocket('Custom error'); // eslint-disable-next-line neverthrow-must-use/must-use-result @@ -399,10 +399,32 @@ describe('SignalClient.connect', () => { readyState: 3, }); - // Mock fetch to return 500 + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.ServerUnreachable); + }); + it('should handle WebsocketError from WebSocket rejection as websocket error if server is reachable', async () => { + const customError = ConnectionError.websocket('Custom error'); + + // eslint-disable-next-line neverthrow-must-use/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(customError), + (error) => error as WebSocketError, + ); + mockWebSocketStream({ + opened, + readyState: 3, + }); + + // Mock fetch to return 200 (global.fetch as any).mockResolvedValueOnce({ - status: 500, - text: async () => 'Internal Server Error', + status: 200, + text: async () => 'testplaceholder', }); const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); @@ -410,7 +432,7 @@ describe('SignalClient.connect', () => { expect(result.isErr()).toBe(true); const error = result._unsafeUnwrapErr(); expect(error).toBeInstanceOf(ConnectionError); - expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.InternalError); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.WebSocket); }); }); @@ -489,6 +511,12 @@ describe('SignalClient.connect', () => { websocketTimeout: 100, }; + // Mock fetch to return 200 + (global.fetch as any).mockResolvedValueOnce({ + status: 200, + text: async () => 'testplaceholder', + }); + const result = await signalClient.join( 'wss://test.livekit.io', 'test-token', @@ -497,8 +525,8 @@ describe('SignalClient.connect', () => { expect(result.isErr()).toBe(true); const error = result._unsafeUnwrapErr(); - // When connection fails, it will timeout since opened never resolves expect(error).toBeInstanceOf(ConnectionError); + expect(error.reason).toBe(ConnectionErrorReason.WebSocket); }); }); diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 24a54b6272..b3aec8d7a7 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -374,7 +374,7 @@ export class SignalClient { withTimeout(firstMessageOrClose, 5_000), abortSignal, ).orTee((error) => { - console.warn('signal connection aborted'); + self.log.warn('signal connection failed', error); if (error.reason === ConnectionErrorReason.Cancelled) { self .sendLeave() @@ -978,18 +978,22 @@ export class SignalClient { return err(reason); } else { return err( - ConnectionError.internal( + ConnectionError.websocket( `Encountered unknown websocket error during connection: ${reason}`, - { status: resp.status, statusText: resp.statusText }, + resp?.status, + resp?.statusText, ), ); } } catch (e) { + if (!(e instanceof ConnectionError)) { + console.warn('received unexpected error', e); + } return err( e instanceof ConnectionError ? e : ConnectionError.serverUnreachable( - e instanceof Error ? e.message : 'server was not reachable', + e instanceof Error ? `${e.name}: ${e.message}` : 'server was not reachable', ), ); } From c2590a278e0246d84036ffd0ee0a2ebd9b3fdef5 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 21 Nov 2025 11:53:58 +0100 Subject: [PATCH 54/58] lint --- src/room/RegionUrlProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/RegionUrlProvider.ts b/src/room/RegionUrlProvider.ts index 00b01e8ce5..acc4dc9ab9 100644 --- a/src/room/RegionUrlProvider.ts +++ b/src/room/RegionUrlProvider.ts @@ -1,6 +1,6 @@ import { Mutex } from '@livekit/mutex'; -import type { RegionInfo, RegionSettings } from '@livekit/protocol'; import { ResultAsync, errAsync, okAsync, safeTry } from 'neverthrow'; +import type { RegionInfo, RegionSettings } from '@livekit/protocol'; import log from '../logger'; import { ConnectionError, ConnectionErrorReason } from './errors'; import { extractMaxAgeFromRequestHeaders, isCloud } from './utils'; From d31e5da7a1239900b147d569b29ad0d154f80cfc Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 21 Nov 2025 13:25:15 +0100 Subject: [PATCH 55/58] forgot to merge utils --- src/api/utils.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/api/utils.ts b/src/api/utils.ts index 21edeeb8d6..0547ba6edd 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,11 +1,8 @@ import { SignalResponse } from '@livekit/protocol'; import { Result, ResultAsync, errAsync } from 'neverthrow'; import type { Mutex } from '@livekit/mutex'; -<<<<<<< HEAD import type TypedEventEmitter from 'typed-emitter'; import type { EventMap } from 'typed-emitter'; -======= ->>>>>>> main import { ConnectionError } from '../room/errors'; import { toHttpUrl, toWebsocketUrl } from '../room/utils'; @@ -182,7 +179,6 @@ export function raceResults[]>( } export type ResultAsyncLike = ResultAsync | Promise>; -<<<<<<< HEAD export function resultFromEvent( emitter: TypedEventEmitter, @@ -195,5 +191,3 @@ export function resultFromEvent( }); return ResultAsync.fromSafePromise(resultPromise); } -======= ->>>>>>> main From a58e221b881023a398ba53fb391a241678684e39 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 21 Nov 2025 15:15:51 +0100 Subject: [PATCH 56/58] Revert "Revert "Typesafe error propagation in signal connection path (#1747)" (#1762)" This reverts commit d6f9f1e69f13029a876ff86e36d6acb0b6f1c18c. --- .changeset/early-numbers-build.md | 5 + eslint.config.mjs | 12 + package.json | 2 + pnpm-lock.yaml | 34 +- src/api/SignalClient.test.ts | 360 ++++++++++------- src/api/SignalClient.ts | 478 +++++++++++------------ src/api/WebSocketStream.test.ts | 115 ++++-- src/api/WebSocketStream.ts | 163 +++++--- src/api/utils.ts | 128 ++++++ src/connectionHelper/checks/turn.ts | 5 +- src/connectionHelper/checks/websocket.ts | 16 +- src/e2ee/E2eeManager.ts | 2 +- src/room/PCTransport.ts | 4 +- src/room/PCTransportManager.ts | 29 +- src/room/RTCEngine.ts | 256 ++++++------ src/room/RegionUrlProvider.test.ts | 12 +- src/room/RegionUrlProvider.ts | 21 +- src/room/Room.ts | 32 +- src/room/errors.ts | 126 +++++- 19 files changed, 1116 insertions(+), 684 deletions(-) create mode 100644 .changeset/early-numbers-build.md diff --git a/.changeset/early-numbers-build.md b/.changeset/early-numbers-build.md new file mode 100644 index 0000000000..2876f3fed2 --- /dev/null +++ b/.changeset/early-numbers-build.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +Typesafe error propagation in signal connection path diff --git a/eslint.config.mjs b/eslint.config.mjs index 246c84842a..5c061396e6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,7 @@ import js from '@eslint/js'; import { configs, plugins, rules } from 'eslint-config-airbnb-extended'; import { rules as prettierConfigRules } from 'eslint-config-prettier'; +import neverthrowMustUse from 'eslint-plugin-neverthrow-must-use'; import prettierPlugin from 'eslint-plugin-prettier'; const strictness = 'off'; @@ -31,6 +32,15 @@ const typescriptConfig = [ rules.typescript.typescriptEslintStrict, ]; +const neverthrowConfig = [ + { + name: 'neverthrow-must-use', + plugins: { + 'neverthrow-must-use': neverthrowMustUse, + }, + }, +]; + const prettierConfig = [ // Prettier Plugin { @@ -56,6 +66,7 @@ export default [ ...typescriptConfig, // Prettier Config ...prettierConfig, + ...neverthrowConfig, { languageOptions: { parserOptions: { @@ -158,6 +169,7 @@ export default [ 'one-var': strictness, 'no-multi-assign': strictness, 'new-cap': strictness, + 'require-yield': strictness, radix: strictness, eqeqeq: strictness, diff --git a/package.json b/package.json index 3fa231d8fb..b089fe9274 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", + "neverthrow": "^8.2.0", "sdp-transform": "^2.15.0", "ts-debounce": "^4.0.0", "tslib": "2.8.1", @@ -96,6 +97,7 @@ "eslint-config-prettier": "10.1.8", "eslint-plugin-compat": "^6.0.2", "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-neverthrow-must-use": "^0.1.2", "eslint-plugin-prettier": "^5.5.4", "gh-pages": "6.3.0", "happy-dom": "^17.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4ec70fc34..ce99d24059 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: loglevel: specifier: ^1.9.2 version: 1.9.2 + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 sdp-transform: specifier: ^2.15.0 version: 2.15.0 @@ -120,6 +123,9 @@ importers: eslint-plugin-import-x: specifier: ^4.16.1 version: 4.16.1(@typescript-eslint/utils@8.47.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.4.2)) + eslint-plugin-neverthrow-must-use: + specifier: ^0.1.2 + version: 0.1.2(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.39.1(jiti@2.4.2)) eslint-plugin-prettier: specifier: ^5.5.4 version: 5.5.4(@types/eslint@8.44.7)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.4.2)))(eslint@9.39.1(jiti@2.4.2))(prettier@3.6.2) @@ -2270,6 +2276,13 @@ packages: peerDependencies: eslint: '>=8.23.0' + eslint-plugin-neverthrow-must-use@0.1.2: + resolution: {integrity: sha512-Wt/u1wjnH8rWtbc8zqTK5yOcB79zVlCCWXi6ChJNer5ACkqldNQ6/+RKVUErACbv0Oex9aqaKYoTd0OqLe4o3Q==} + engines: {node: '>=16'} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 + eslint: ^9.0.0 + eslint-plugin-prettier@5.5.4: resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2982,6 +2995,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neverthrow@8.2.0: + resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} + engines: {node: '>=18'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3624,8 +3641,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20251120: - resolution: {integrity: sha512-dkvZw2/09r7JIltGCeubJXLYE7+NapbKj68BtGtm47TiwjyKxTDTG2nWZu8Gpopzi0ub9bNVn0rEgh5CgOlE4w==} + typescript@6.0.0-dev.20251121: + resolution: {integrity: sha512-TrGhGS4hOAKgwizhMuH/3pbTNNBMCpxRA7ia8Lrv4HRMOAOzI5lWhP5uoKRDmmaF3pUVe90MBYjSieM498zUqQ==} engines: {node: '>=14.17'} hasBin: true @@ -6023,7 +6040,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20251120 + typescript: 6.0.0-dev.20251121 dunder-proto@1.0.1: dependencies: @@ -6331,6 +6348,11 @@ snapshots: - typescript optional: true + eslint-plugin-neverthrow-must-use@0.1.2(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.39.1(jiti@2.4.2)): + dependencies: + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.39.1(jiti@2.4.2) + eslint-plugin-prettier@5.5.4(@types/eslint@8.44.7)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.4.2)))(eslint@9.39.1(jiti@2.4.2))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.4.2) @@ -7088,6 +7110,10 @@ snapshots: neo-async@2.6.2: {} + neverthrow@8.2.0: + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.53.2 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -7789,7 +7815,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20251120: {} + typescript@6.0.0-dev.20251121: {} uc.micro@2.1.0: {} diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index 0359a50295..ffec63ce39 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -5,11 +5,12 @@ import { SignalRequest, SignalResponse, } from '@livekit/protocol'; +import { Result, ResultAsync } from 'neverthrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionError, ConnectionErrorReason } from '../room/errors'; -import { SignalClient, SignalConnectionState } from './SignalClient'; +import { SignalClient, SignalConnectionState, type ValidationType } from './SignalClient'; import type { WebSocketCloseInfo, WebSocketConnection } from './WebSocketStream'; -import { WebSocketStream } from './WebSocketStream'; +import { WebSocketError, WebSocketStream } from './WebSocketStream'; // Mock the WebSocketStream vi.mock('./WebSocketStream'); @@ -57,16 +58,27 @@ function createMockConnection(readable: ReadableStream): WebSocketC interface MockWebSocketStreamOptions { connection?: WebSocketConnection; - opened?: Promise; - closed?: Promise; + opened?: ResultAsync, WebSocketError>; + closed?: ResultAsync; readyState?: number; } function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) { const { connection, - opened = connection ? Promise.resolve(connection) : new Promise(() => {}), - closed = new Promise(() => {}), + // eslint-disable-next-line neverthrow-must-use/must-use-result + opened = connection + ? ResultAsync.fromPromise(Promise.resolve(connection), (error) => ({ + type: 'connection' as const, + error: error as Event, + })) + : // eslint-disable-next-line neverthrow-must-use/must-use-result + ResultAsync.fromPromise(new Promise(() => {}), (error) => ({ + type: 'connection' as const, + error: error as Event, + })), + // eslint-disable-next-line neverthrow-must-use/must-use-result + closed = ResultAsync.fromPromise(new Promise(() => {}), (error) => error as WebSocketError), readyState = 1, } = options; @@ -109,7 +121,7 @@ describe('SignalClient.connect', () => { const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); - expect(result).toEqual(joinResponse); + expect(result._unsafeUnwrap()).toEqual(joinResponse); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); }); @@ -138,7 +150,7 @@ describe('SignalClient.connect', () => { const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123'); - expect(result).toEqual(reconnectResponse); + expect(result._unsafeUnwrap()).toEqual(reconnectResponse); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }); @@ -163,7 +175,7 @@ describe('SignalClient.connect', () => { const result = await signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123'); // This is an edge case: reconnect resolves with undefined when non-reconnect message is received - expect(result).toBeUndefined(); + expect(result._unsafeUnwrap()).toBeUndefined(); expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED); }, 1000); }); @@ -177,9 +189,14 @@ describe('SignalClient.connect', () => { websocketTimeout: 100, }; - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', shortTimeoutOptions), - ).rejects.toThrow(ConnectionError); + const result = await signalClient.join( + 'wss://test.livekit.io', + 'test-token', + shortTimeoutOptions, + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); }); }); @@ -191,23 +208,35 @@ describe('SignalClient.connect', () => { // Simulate abort setTimeout(() => abortController.abort(new Error('User aborted connection')), 50); + // eslint-disable-next-line neverthrow-must-use/must-use-result + const opened = ResultAsync.fromPromise(new Promise(() => {}), (error) => ({ + type: 'connection' as const, + error: error as Event, + })); + // eslint-disable-next-line neverthrow-must-use/must-use-result + const closed = ResultAsync.fromPromise( + new Promise(() => {}), + (error) => error as WebSocketError, + ); + return { url: 'wss://test.livekit.io', - opened: new Promise(() => {}), // Never resolves - closed: new Promise(() => {}), + opened, + closed, close: vi.fn(), readyState: 0, } as any; }); - await expect( - signalClient.join( - 'wss://test.livekit.io', - 'test-token', - defaultOptions, - abortController.signal, - ), - ).rejects.toThrow('User aborted connection'); + const result = await signalClient.join( + 'wss://test.livekit.io', + 'test-token', + defaultOptions, + abortController.signal, + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().message).toBe('AbortSignal invoked'); }); it('should send leave request before closing when AbortSignal is triggered during connection', async () => { @@ -249,10 +278,21 @@ describe('SignalClient.connect', () => { }; vi.mocked(WebSocketStream).mockImplementation(() => { + // eslint-disable-next-line neverthrow-must-use/must-use-result + const opened = ResultAsync.fromPromise(Promise.resolve(mockConnection), (error) => ({ + type: 'connection' as const, + error: error as Event, + })); + // eslint-disable-next-line neverthrow-must-use/must-use-result + const closed = ResultAsync.fromPromise( + new Promise(() => {}), + (error) => error as WebSocketError, + ); + return { url: 'wss://test.livekit.io', - opened: Promise.resolve(mockConnection), - closed: new Promise(() => {}), + opened, + closed, close: vi.fn(), readyState: 1, } as any; @@ -270,10 +310,12 @@ describe('SignalClient.connect', () => { await streamWriterReadyPromise; // Now abort the connection (after WS opens, before join response) - abortController.abort(new Error('User aborted connection')); + abortController.abort(); - // joinPromise should reject - await expect(joinPromise).rejects.toThrow('User aborted connection'); + // joinPromise should return Err result + const result = await joinPromise; + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().message).toBe('AbortSignal invoked'); // Verify that a leave request was sent before closing const leaveRequestSent = writtenMessages.some((data) => { @@ -296,8 +338,13 @@ describe('SignalClient.connect', () => { describe('Failure Case - WebSocket Connection Errors', () => { it('should reject with NotAllowed error for 4xx HTTP status', async () => { + // eslint-disable-next-line neverthrow-must-use/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(ConnectionError.websocket('Connection failed')), + (error) => error as WebSocketError, + ); mockWebSocketStream({ - opened: Promise.reject(new Error('Connection failed')), + opened, readyState: 3, }); @@ -307,54 +354,85 @@ describe('SignalClient.connect', () => { text: async () => 'Forbidden', }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'Forbidden', - reason: ConnectionErrorReason.NotAllowed, - status: 403, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('Forbidden'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.NotAllowed); + expect((error as ConnectionError).status).toBe(403); }); it('should reject with ServerUnreachable when fetch fails', async () => { + // eslint-disable-next-line neverthrow-must-use/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(ConnectionError.websocket('Connection failed')), + (error) => error as WebSocketError, + ); mockWebSocketStream({ - opened: Promise.reject(new Error('Connection failed')), + opened, readyState: 3, }); // Mock fetch to throw (network error) (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - reason: ConnectionErrorReason.ServerUnreachable, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.ServerUnreachable); }); - it('should handle ConnectionError from WebSocket rejection', async () => { - const customError = new ConnectionError( - 'Custom error', - ConnectionErrorReason.InternalError, - 500, + it('should handle WebsocketError from WebSocket rejection as unreachable if server is not reachable', async () => { + const customError = ConnectionError.websocket('Custom error'); + + // eslint-disable-next-line neverthrow-must-use/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(customError), + (error) => error as WebSocketError, ); + mockWebSocketStream({ + opened, + readyState: 3, + }); + + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.ServerUnreachable); + }); + it('should handle WebsocketError from WebSocket rejection as websocket error if server is reachable', async () => { + const customError = ConnectionError.websocket('Custom error'); + + // eslint-disable-next-line neverthrow-must-use/must-use-result + const opened = ResultAsync.fromPromise( + Promise.reject(customError), + (error) => error as WebSocketError, + ); mockWebSocketStream({ - opened: Promise.reject(customError), + opened, readyState: 3, }); - // Mock fetch to return 500 + // Mock fetch to return 200 (global.fetch as any).mockResolvedValueOnce({ - status: 500, - text: async () => 'Internal Server Error', + status: 200, + text: async () => 'testplaceholder', }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - reason: ConnectionErrorReason.InternalError, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.WebSocket); }); }); @@ -370,12 +448,13 @@ describe('SignalClient.connect', () => { mockWebSocketStream({ connection: mockConnection }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'no message received as first message', - reason: ConnectionErrorReason.InternalError, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('no message received as first message'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.InternalError); }); }); @@ -390,16 +469,14 @@ describe('SignalClient.connect', () => { mockWebSocketStream({ connection: mockConnection }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject( - new ConnectionError( - 'Received leave request while trying to (re)connect', - ConnectionErrorReason.LeaveRequest, - undefined, - 1, - ), - ); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('Received leave request while trying to (re)connect'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.LeaveRequest); + expect((error as ConnectionError).context).toBe(1); }); }); @@ -415,43 +492,41 @@ describe('SignalClient.connect', () => { mockWebSocketStream({ connection: mockConnection }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'did not receive join response, got reconnect instead', - reason: ConnectionErrorReason.InternalError, - }); + const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.message).toBe('did not receive join response, got reconnect instead'); + expect((error as ConnectionError).reason).toBe(ConnectionErrorReason.InternalError); }); }); describe('Failure Case - WebSocket Closed During Connection', () => { it('should reject when WebSocket closes during connection attempt', async () => { - let closedResolve: (value: WebSocketCloseInfo) => void; - const closedPromise = new Promise((resolve) => { - closedResolve = resolve; - }); + mockWebSocketStream({ readyState: 3 }); // CLOSED - vi.mocked(WebSocketStream).mockImplementation(() => { - // Simulate close during connection - queueMicrotask(() => { - closedResolve({ closeCode: 1006, reason: 'Connection lost' }); - }); + const shortTimeoutOptions = { + ...defaultOptions, + websocketTimeout: 100, + }; - return { - url: 'wss://test.livekit.io', - opened: new Promise(() => {}), // Never resolves - closed: closedPromise, - close: vi.fn(), - readyState: 2, // CLOSING - } as any; + // Mock fetch to return 200 + (global.fetch as any).mockResolvedValueOnce({ + status: 200, + text: async () => 'testplaceholder', }); - await expect( - signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions), - ).rejects.toMatchObject({ - message: 'Websocket got closed during a (re)connection attempt: Connection lost', - reason: ConnectionErrorReason.InternalError, - }); + const result = await signalClient.join( + 'wss://test.livekit.io', + 'test-token', + shortTimeoutOptions, + ); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(ConnectionError); + expect(error.reason).toBe(ConnectionErrorReason.WebSocket); }); }); @@ -587,11 +662,13 @@ describe('SignalClient.validateFirstMessage', () => { const joinResponse = createJoinResponse(); const signalResponse = createSignalResponse('join', joinResponse); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); - expect(result.isValid).toBe(true); - expect(result.response).toEqual(joinResponse); + expect(result._unsafeUnwrap().response).toEqual(joinResponse); } }); @@ -611,11 +688,13 @@ describe('SignalClient.validateFirstMessage', () => { const reconnectResponse = new ReconnectResponse({ iceServers: [] }); const signalResponse = createSignalResponse('reconnect', reconnectResponse); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, true); - expect(result.isValid).toBe(true); - expect(result.response).toEqual(reconnectResponse); + expect(result._unsafeUnwrap().response).toEqual(reconnectResponse); } }); @@ -634,12 +713,14 @@ describe('SignalClient.validateFirstMessage', () => { const updateSignalResponse = createSignalResponse('update', { participants: [] }); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, updateSignalResponse, true); - expect(result.isValid).toBe(true); - expect(result.response).toBeUndefined(); - expect(result.shouldProcessFirstMessage).toBe(true); + expect(result._unsafeUnwrap().response).toBeUndefined(); + expect(result._unsafeUnwrap().shouldProcessFirstMessage).toBe(true); } }); @@ -650,12 +731,14 @@ describe('SignalClient.validateFirstMessage', () => { const leaveRequest = new LeaveRequest({ reason: 1 }); const signalResponse = createSignalResponse('leave', leaveRequest); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); - expect(result.isValid).toBe(false); - expect(result.error).toBeInstanceOf(ConnectionError); - expect(result.error?.reason).toBe(ConnectionErrorReason.LeaveRequest); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); + expect(result._unsafeUnwrapErr().reason).toBe(ConnectionErrorReason.LeaveRequest); } }); @@ -663,12 +746,14 @@ describe('SignalClient.validateFirstMessage', () => { const reconnectResponse = new ReconnectResponse({ iceServers: [] }); const signalResponse = createSignalResponse('reconnect', reconnectResponse); - const validateMethod = (signalClient as any).validateFirstMessage; + const validateMethod = (signalClient as any).validateFirstMessage as ( + msg: any, + isReconnect: boolean, + ) => Result; if (validateMethod) { const result = validateMethod.call(signalClient, signalResponse, false); - expect(result.isValid).toBe(false); - expect(result.error).toBeInstanceOf(ConnectionError); - expect(result.error?.reason).toBe(ConnectionErrorReason.InternalError); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConnectionError); + expect(result._unsafeUnwrapErr().reason).toBe(ConnectionErrorReason.InternalError); } }); }); @@ -692,19 +777,17 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBeInstanceOf(ConnectionError); - expect(result.reason).toBe(ConnectionErrorReason.NotAllowed); - expect(result.status).toBe(403); - expect(result.message).toBe('Forbidden'); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBeInstanceOf(ConnectionError); + expect(err.reason).toBe(ConnectionErrorReason.NotAllowed); + expect(err.status).toBe(403); + expect(err.message).toBe('Forbidden'); } }); it('should return ConnectionError as-is if it is already a ConnectionError', async () => { - const connectionError = new ConnectionError( - 'Custom error', - ConnectionErrorReason.InternalError, - 500, - ); + const connectionError = ConnectionError.internal('Custom error'); (global.fetch as any).mockResolvedValueOnce({ status: 500, @@ -719,8 +802,10 @@ describe('SignalClient.handleConnectionError', () => { 'wss://test.livekit.io/validate', ); - expect(result).toBe(connectionError); - expect(result.reason).toBe(ConnectionErrorReason.InternalError); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBe(connectionError); + expect(err.reason).toBe(ConnectionErrorReason.InternalError); } }); @@ -735,9 +820,11 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBeInstanceOf(ConnectionError); - expect(result.reason).toBe(ConnectionErrorReason.InternalError); - expect(result.status).toBe(500); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBeInstanceOf(ConnectionError); + expect(err.reason).toBe(ConnectionErrorReason.InternalError); + expect(err.status).toBe(500); } }); @@ -749,13 +836,15 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBeInstanceOf(ConnectionError); - expect(result.reason).toBe(ConnectionErrorReason.ServerUnreachable); + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err).toBeInstanceOf(ConnectionError); + expect(err.reason).toBe(ConnectionErrorReason.ServerUnreachable); } }); it('should handle fetch throwing ConnectionError', async () => { - const fetchError = new ConnectionError('Fetch failed', ConnectionErrorReason.ServerUnreachable); + const fetchError = ConnectionError.serverUnreachable('Fetch failed'); (global.fetch as any).mockRejectedValueOnce(fetchError); const handleMethod = (signalClient as any).handleConnectionError; @@ -763,7 +852,8 @@ describe('SignalClient.handleConnectionError', () => { const error = new Error('Connection failed'); const result = await handleMethod.call(signalClient, error, 'wss://test.livekit.io/validate'); - expect(result).toBe(fetchError); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBe(fetchError); } }); }); diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index abf44e563a..b3aec8d7a7 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -44,6 +44,7 @@ import { WrappedJoinRequest, protoInt64, } from '@livekit/protocol'; +import { Result, ResultAsync, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; import log, { LoggerNames, getLogger } from '../logger'; import { ConnectionError, ConnectionErrorReason } from '../room/errors'; import CriticalTimers from '../room/timers'; @@ -54,14 +55,15 @@ import { type WebSocketConnection, WebSocketStream } from './WebSocketStream'; import { createRtcUrl, createValidateUrl, - getAbortReasonAsString, parseSignalResponse, + raceResults, + withAbort, + withMutex, + withTimeout, } from './utils'; // internal options interface ConnectOpts extends SignalOptions { - /** internal */ - reconnect?: boolean; /** internal */ reconnectReason?: number; /** internal */ @@ -85,7 +87,6 @@ type SignalKind = NonNullable['case']; const passThroughQueueSignals: Array = [ 'syncState', 'trickle', - 'offer', 'answer', 'simulate', 'leave', @@ -241,229 +242,174 @@ export class SignalClient { return this.loggerContextCb?.() ?? {}; } - async join( - url: string, - token: string, - opts: SignalOptions, - abortSignal?: AbortSignal, - ): Promise { + async join(url: string, token: string, opts: SignalOptions, abortSignal?: AbortSignal) { // during a full reconnect, we'd want to start the sequence even if currently // connected this.state = SignalConnectionState.CONNECTING; this.options = opts; - const res = await this.connect(url, token, opts, abortSignal); - return res as JoinResponse; + return this.connect(url, token, false, opts, abortSignal); } - async reconnect( - url: string, - token: string, - sid?: string, - reason?: ReconnectReason, - ): Promise { + reconnect(url: string, token: string, sid?: string, reason?: ReconnectReason) { if (!this.options) { - this.log.warn( - 'attempted to reconnect without signal options being set, ignoring', - this.logContext, + return errAsync( + ConnectionError.internal('attempted to reconnect without signal options being set'), ); - return; } this.state = SignalConnectionState.RECONNECTING; // clear ping interval and restart it once reconnected this.clearPingInterval(); - const res = (await this.connect(url, token, { + return this.connect(url, token, true, { ...this.options, - reconnect: true, sid, reconnectReason: reason, - })) as ReconnectResponse | undefined; - return res; + }); } - private async connect( - url: string, - token: string, - opts: ConnectOpts, - abortSignal?: AbortSignal, - ): Promise { - const unlock = await this.connectionLock.lock(); - - this.connectOptions = opts; - const clientInfo = getClientInfo(); - const params = opts.singlePeerConnection - ? createJoinRequestConnectionParams(token, clientInfo, opts) - : createConnectionParams(token, clientInfo, opts); - const rtcUrl = createRtcUrl(url, params); - const validateUrl = createValidateUrl(rtcUrl); - - return new Promise(async (resolve, reject) => { - try { - let alreadyAborted = false; - const abortHandler = async (eventOrError: Event | Error) => { - if (alreadyAborted) { - return; - } - alreadyAborted = true; - const target = eventOrError instanceof Event ? eventOrError.currentTarget : eventOrError; - const reason = getAbortReasonAsString(target, 'Abort handler called'); - // send leave if we have an active stream writer (connection is open) - if (this.streamWriter && !this.isDisconnected) { - this.sendLeave() - .then(() => this.close(reason)) - .catch((e) => { - this.log.error(e); - this.close(); - }); - } else { - this.close(); - } - cleanupAbortHandlers(); - reject(target instanceof AbortSignal ? target.reason : target); - }; - - abortSignal?.addEventListener('abort', abortHandler); - - const cleanupAbortHandlers = () => { - clearTimeout(wsTimeout); - abortSignal?.removeEventListener('abort', abortHandler); - }; - - const wsTimeout = setTimeout(() => { - abortHandler( - new ConnectionError( - 'room connection has timed out (signal)', - ConnectionErrorReason.ServerUnreachable, - ), - ); - }, opts.websocketTimeout); + private connect< + T extends boolean, + U extends T extends false ? JoinResponse : ReconnectResponse | undefined, + >(url: string, token: string, isReconnect: T, opts: ConnectOpts, abortSignal?: AbortSignal) { + const self = this; - const handleSignalConnected = ( - connection: WebSocketConnection, - firstMessage?: SignalResponse, - ) => { - this.handleSignalConnected(connection, wsTimeout, firstMessage); - }; + return withMutex( + safeTry(async function* () { + self.connectOptions = opts; + + const clientInfo = getClientInfo(); + const params = opts.singlePeerConnection + ? createJoinRequestConnectionParams(token, clientInfo, opts, isReconnect) + : createConnectionParams(token, clientInfo, opts, isReconnect); + const rtcUrl = createRtcUrl(url, params); + const validateUrl = createValidateUrl(rtcUrl); const redactedUrl = new URL(rtcUrl); if (redactedUrl.searchParams.has('access_token')) { redactedUrl.searchParams.set('access_token', ''); } - this.log.debug(`connecting to ${redactedUrl}`, { - reconnect: opts.reconnect, + self.log.debug(`connecting to ${redactedUrl}`, { + reconnect: isReconnect, reconnectReason: opts.reconnectReason, - ...this.logContext, + ...self.logContext, }); - if (this.ws) { - await this.close(false); + + if (self.ws) { + await self.close( + false, + opts?.reconnectReason ? ReconnectReason[opts.reconnectReason] : undefined, + ); } - this.ws = new WebSocketStream(rtcUrl); - - try { - this.ws.closed - .then((closeInfo) => { - if (this.isEstablishingConnection) { - reject( - new ConnectionError( - `Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`, - ConnectionErrorReason.InternalError, - ), - ); + + const ws = new WebSocketStream(rtcUrl); + self.ws = ws; + + const wsConnectionResult = withTimeout(ws.opened, opts.websocketTimeout).mapErr( + async (error) => { + // retrieve info about what error was causing the connection failure and enhance the returned error + if (self.state !== SignalConnectionState.CONNECTED) { + self.state = SignalConnectionState.DISCONNECTED; + const connectionError = await withAbort( + withTimeout(self.fetchErrorInfo(error.message, validateUrl), 3_000), + abortSignal, + ); + + const closeReason = `${error.reason}: ${error.message}`; + + self.close(undefined, closeReason); + if (connectionError.isErr()) { + return connectionError.error; } - if (closeInfo.closeCode !== 1000) { - this.log.warn(`websocket closed`, { - ...this.logContext, + } + return error; + }, + ); + + const wsConnection = yield* withAbort( + wsConnectionResult.andTee((connection) => { + self.streamWriter = connection.writable.getWriter(); + }), + abortSignal, + ).orTee((error) => { + self.close(undefined, error.message); + }); + + const firstMessageOrClose = raceResults([ + self.processInitialSignalMessage(wsConnection), + // Return the close promise as error if it resolves first + ws.closed + .orTee((error) => { + self.handleWSError(error); + }) + .andThen((closeInfo) => { + if ( + // we only log the warning here if the current ws connection is still the same, we don't care about closing of older ws connections that have been replaced + ws === self.ws + ) { + self.log.warn(`websocket closed`, { + ...self.logContext, reason: closeInfo.reason, code: closeInfo.closeCode, wasClean: closeInfo.closeCode === 1000, - state: this.state, + state: self.state, }); - if (this.state === SignalConnectionState.CONNECTED) { - this.handleOnClose(closeInfo.reason ?? 'Unexpected WS error'); + if (self.state == SignalConnectionState.CONNECTED) { + self.handleOnClose(closeInfo.reason ?? 'Websocket closed unexpectedly'); + } else { + self.log.warn( + `ws closed unexpectedly in state ${SignalConnectionState[self.state]}`, + ); } } - return; - }) - .catch((reason) => { - if (this.isEstablishingConnection) { - reject( - new ConnectionError( - `Websocket error during a (re)connection attempt: ${reason}`, - ConnectionErrorReason.InternalError, - ), - ); - } - }); - const connection = await this.ws.opened.catch(async (reason: unknown) => { - if (this.state !== SignalConnectionState.CONNECTED) { - this.state = SignalConnectionState.DISCONNECTED; - clearTimeout(wsTimeout); - const error = await this.handleConnectionError(reason, validateUrl); - reject(error); - return; - } - // other errors, handle - this.handleWSError(reason); - reject(reason); - return; - }); - clearTimeout(wsTimeout); - if (!connection) { - return; - } - const signalReader = connection.readable.getReader(); - this.streamWriter = connection.writable.getWriter(); - const firstMessage = await signalReader.read(); - signalReader.releaseLock(); - if (!firstMessage.value) { - throw new ConnectionError( - 'no message received as first message', - ConnectionErrorReason.InternalError, - ); - } - - const firstSignalResponse = parseSignalResponse(firstMessage.value); - // Validate the first message - const validation = this.validateFirstMessage( - firstSignalResponse, - opts.reconnect ?? false, - ); - - if (!validation.isValid) { - reject(validation.error); - return; - } - - // Handle join response - set up ping configuration - if (firstSignalResponse.message?.case === 'join') { - this.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; - this.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; - - if (this.pingTimeoutDuration && this.pingTimeoutDuration > 0) { - this.log.debug('ping config', { - ...this.logContext, - timeout: this.pingTimeoutDuration, - interval: this.pingIntervalDuration, + return err( + ConnectionError.internal( + closeInfo.reason ?? 'Websocket closed during (re)connection attempt', + ), + ); + }), + ]); + + const firstSignalResponse = yield* await withAbort( + withTimeout(firstMessageOrClose, 5_000), + abortSignal, + ).orTee((error) => { + self.log.warn('signal connection failed', error); + if (error.reason === ConnectionErrorReason.Cancelled) { + self + .sendLeave() + .then(() => self.close()) + .catch((e) => { + self.log.error(e); + self.close(); }); - } } + }); - // Handle successful connection - const firstMessageToProcess = validation.shouldProcessFirstMessage - ? firstSignalResponse - : undefined; - handleSignalConnected(connection, firstMessageToProcess); - resolve(validation.response); - } catch (e) { - reject(e); - } finally { - cleanupAbortHandlers(); + const validation = yield* self.validateFirstMessage(firstSignalResponse, isReconnect); + + // Handle join response - set up ping configuration + if (firstSignalResponse.message?.case === 'join') { + self.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout; + self.pingIntervalDuration = firstSignalResponse.message.value.pingInterval; + if (self.pingTimeoutDuration && self.pingTimeoutDuration > 0) { + self.log.debug('ping config', { + ...self.logContext, + timeout: self.pingTimeoutDuration, + interval: self.pingIntervalDuration, + }); + } } - } finally { - unlock(); - } - }); + + self.handleSignalConnected( + wsConnection, + validation.shouldProcessFirstMessage ? firstSignalResponse : undefined, + ); + + return ok(validation.response as U); + }), + this.connectionLock, + ); } async startReadingLoop( @@ -512,24 +458,31 @@ export class SignalClient { return; } const unlock = await this.closingLock.lock(); + try { this.clearPingInterval(); if (updateState) { this.state = SignalConnectionState.DISCONNECTING; } if (this.ws) { - this.ws.close({ closeCode: 1000, reason }); - - // calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED - const closePromise = this.ws.closed; + const ws = this.ws; this.ws = undefined; this.streamWriter = undefined; + ws.close({ closeCode: 1000, reason }); + + // calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED + const closePromise = ws.closed.match( + (closeInfo) => closeInfo, + (error) => error, + ); + await Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]); + this.log.info('closed websocket', { reason }); } } catch (e) { this.log.debug('websocket error while closing', { ...this.logContext, error: e }); } finally { - if (updateState) { + if (updateState && this.state === SignalConnectionState.DISCONNECTING) { this.state = SignalConnectionState.DISCONNECTED; } unlock(); @@ -538,8 +491,13 @@ export class SignalClient { // initial offer after joining sendOffer(offer: RTCSessionDescriptionInit, offerId: number) { - this.log.debug('sending offer', { ...this.logContext, offerSdp: offer.sdp }); - this.sendRequest({ + this.log.debug('sending offer', { + ...this.logContext, + offerSdp: offer.sdp, + state: SignalConnectionState[this.state], + wsState: this.ws?.readyState, + }); + return this.sendRequest({ case: 'offer', value: toProtoSessionDescription(offer, offerId), }); @@ -847,13 +805,13 @@ export class SignalClient { if (this.state === SignalConnectionState.DISCONNECTED) return; const onCloseCallback = this.onClose; await this.close(undefined, reason); - this.log.debug(`websocket connection closed: ${reason}`, { ...this.logContext, reason }); + this.log.debug(`websocket connection closing: ${reason}`, { ...this.logContext, reason }); if (onCloseCallback) { onCloseCallback(reason); } } - private handleWSError(error: unknown) { + private handleWSError(error: ReturnType) { this.log.error('websocket error', { ...this.logContext, error }); } @@ -915,17 +873,30 @@ export class SignalClient { * @param firstMessage Optional first message to process * @internal */ - private handleSignalConnected( - connection: WebSocketConnection, - timeoutHandle: ReturnType, - firstMessage?: SignalResponse, - ) { + private handleSignalConnected(connection: WebSocketConnection, firstMessage?: SignalResponse) { this.state = SignalConnectionState.CONNECTED; - clearTimeout(timeoutHandle); this.startPingInterval(); this.startReadingLoop(connection.readable.getReader(), firstMessage); } + private processInitialSignalMessage( + connection: WebSocketConnection, + ): ResultAsync { + // TODO: If inferring from the return type this could be more granular here than ConnectionError + return safeTry(async function* () { + const signalReader = connection.readable.getReader(); + + const firstMessage = await signalReader.read().finally(() => signalReader.releaseLock()); + if (!firstMessage.value) { + return err(ConnectionError.internal('no message received as first message')); + } + + const firstSignalResponse = parseSignalResponse(firstMessage.value); + + return okAsync(firstSignalResponse); + }); + } + /** * Validates the first message received from the signal server * @param firstSignalResponse The first signal response received @@ -936,63 +907,54 @@ export class SignalClient { private validateFirstMessage( firstSignalResponse: SignalResponse, isReconnect: boolean, - ): { - isValid: boolean; - response?: JoinResponse | ReconnectResponse; - error?: ConnectionError; - shouldProcessFirstMessage?: boolean; - } { - if (firstSignalResponse.message?.case === 'join') { - return { - isValid: true, + ): Result< + ValidationType, + // TODO, this should probably not be a ConnectionError? + ConnectionError + > { + if (isReconnect === false && firstSignalResponse.message?.case === 'join') { + return ok({ response: firstSignalResponse.message.value, - }; + shouldProcessFirstMessage: false, + }); } else if ( + isReconnect === true && this.state === SignalConnectionState.RECONNECTING && firstSignalResponse.message?.case !== 'leave' ) { if (firstSignalResponse.message?.case === 'reconnect') { - return { - isValid: true, + return ok({ response: firstSignalResponse.message.value, - }; + shouldProcessFirstMessage: false, + }); } else { // in reconnecting, any message received means signal reconnected and we still need to process it this.log.debug( 'declaring signal reconnected without reconnect response received', this.logContext, ); - return { - isValid: true, + return ok({ response: undefined, shouldProcessFirstMessage: true, - }; + }); } } else if (this.isEstablishingConnection && firstSignalResponse.message?.case === 'leave') { - return { - isValid: false, - error: new ConnectionError( + return err( + ConnectionError.leaveRequest( 'Received leave request while trying to (re)connect', - ConnectionErrorReason.LeaveRequest, - undefined, firstSignalResponse.message.value.reason, ), - }; + ); } else if (!isReconnect) { // non-reconnect case, should receive join response first - return { - isValid: false, - error: new ConnectionError( + + return err( + ConnectionError.internal( `did not receive join response, got ${firstSignalResponse.message?.case} instead`, - ConnectionErrorReason.InternalError, ), - }; + ); } - - return { - isValid: false, - error: new ConnectionError('Unexpected first message', ConnectionErrorReason.InternalError), - }; + return err(ConnectionError.internal('Unexpected first message')); } /** @@ -1002,31 +964,38 @@ export class SignalClient { * @returns A ConnectionError with appropriate reason and status * @internal */ - private async handleConnectionError( + private async fetchErrorInfo( reason: unknown, validateUrl: string, - ): Promise { + ): Promise> { try { const resp = await fetch(validateUrl); + if (resp.status.toFixed(0).startsWith('4')) { const msg = await resp.text(); - return new ConnectionError(msg, ConnectionErrorReason.NotAllowed, resp.status); + return err(ConnectionError.notAllowed(msg, resp.status)); } else if (reason instanceof ConnectionError) { - return reason; + return err(reason); } else { - return new ConnectionError( - `Encountered unknown websocket error during connection: ${reason}`, - ConnectionErrorReason.InternalError, - resp.status, + return err( + ConnectionError.websocket( + `Encountered unknown websocket error during connection: ${reason}`, + resp?.status, + resp?.statusText, + ), ); } } catch (e) { - return e instanceof ConnectionError - ? e - : new ConnectionError( - e instanceof Error ? e.message : 'server was not reachable', - ConnectionErrorReason.ServerUnreachable, - ); + if (!(e instanceof ConnectionError)) { + console.warn('received unexpected error', e); + } + return err( + e instanceof ConnectionError + ? e + : ConnectionError.serverUnreachable( + e instanceof Error ? `${e.name}: ${e.message}` : 'server was not reachable', + ), + ); } } } @@ -1065,12 +1034,13 @@ function createConnectionParams( token: string, info: ClientInfo, opts: ConnectOpts, + isReconnect: boolean, ): URLSearchParams { const params = new URLSearchParams(); params.set('access_token', token); // opts - if (opts.reconnect) { + if (isReconnect) { params.set('reconnect', '1'); if (opts.sid) { params.set('sid', opts.sid); @@ -1120,6 +1090,7 @@ function createJoinRequestConnectionParams( token: string, info: ClientInfo, opts: ConnectOpts, + isReconnect: boolean, ): URLSearchParams { const params = new URLSearchParams(); params.set('access_token', token); @@ -1130,7 +1101,7 @@ function createJoinRequestConnectionParams( autoSubscribe: !!opts.autoSubscribe, adaptiveStream: !!opts.adaptiveStream, }), - reconnect: !!opts.reconnect, + reconnect: isReconnect, participantSid: opts.sid ? opts.sid : undefined, }); if (opts.reconnectReason) { @@ -1143,3 +1114,8 @@ function createJoinRequestConnectionParams( return params; } + +export type ValidationType = + | { response: JoinResponse; shouldProcessFirstMessage: false } + | { response: ReconnectResponse; shouldProcessFirstMessage: false } + | { response: undefined; shouldProcessFirstMessage: true }; diff --git a/src/api/WebSocketStream.test.ts b/src/api/WebSocketStream.test.ts index 3445348042..32d1a5d5ad 100644 --- a/src/api/WebSocketStream.test.ts +++ b/src/api/WebSocketStream.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConnectionErrorReason } from '../room/errors'; import { WebSocketStream } from './WebSocketStream'; // Mock WebSocket @@ -122,6 +123,16 @@ vi.mock('../room/utils', () => ({ sleep: vi.fn((duration: number) => new Promise((resolve) => setTimeout(resolve, duration))), })); +// Helper function to unwrap Result from opened promise +async function getConnectionOrFail(wsStream: WebSocketStream) { + const result = await wsStream.opened; + expect(result.isOk()).toBe(true); + if (!result.isOk()) { + throw new Error('Failed to open connection'); + } + return result.value; +} + describe('WebSocketStream', () => { let mockWebSocket: MockWebSocket; let originalWebSocket: typeof WebSocket; @@ -174,7 +185,7 @@ describe('WebSocketStream', () => { new WebSocketStream('wss://test.example.com', { signal: abortController.signal, }); - }).toThrow('This operation was aborted'); + }).toThrow('Aborted before WS was initialized'); }); it('should close when abort signal is triggered', () => { @@ -201,21 +212,29 @@ describe('WebSocketStream', () => { const removeEventListenerSpy = vi.spyOn(mockWebSocket, 'removeEventListener'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; - - expect(connection.readable).toBeInstanceOf(ReadableStream); - expect(connection.writable).toBeInstanceOf(WritableStream); - expect(connection.protocol).toBe('test-protocol'); - expect(connection.extensions).toBe('test-extension'); + const result = await wsStream.opened; + + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const connection = result.value; + expect(connection.readable).toBeInstanceOf(ReadableStream); + expect(connection.writable).toBeInstanceOf(WritableStream); + expect(connection.protocol).toBe('test-protocol'); + expect(connection.extensions).toBe('test-extension'); + } expect(removeEventListenerSpy).toHaveBeenCalledWith('error', expect.any(Function)); }); - it('should reject when WebSocket errors before opening', async () => { + it('should return error Result when WebSocket errors before opening', async () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerError(); - await expect(wsStream.opened).rejects.toThrow(); + const result = await wsStream.opened; + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.reason).toBe(ConnectionErrorReason.WebSocket); + } }); }); @@ -227,10 +246,13 @@ describe('WebSocketStream', () => { mockWebSocket.triggerOpen(); mockWebSocket.triggerClose(1001, 'Going away'); - const closeInfo = await wsStream.closed; + const result = await wsStream.closed; - expect(closeInfo.closeCode).toBe(1001); - expect(closeInfo.reason).toBe('Going away'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.closeCode).toBe(1001); + expect(result.value.reason).toBe('Going away'); + } expect(removeEventListenerSpy).toHaveBeenCalledWith('error', expect.any(Function)); }); @@ -241,13 +263,16 @@ describe('WebSocketStream', () => { mockWebSocket.triggerError(); mockWebSocket.triggerClose(1006, 'Connection failed'); - const closeInfo = await wsStream.closed; + const result = await wsStream.closed; - expect(closeInfo.closeCode).toBe(1006); - expect(closeInfo.reason).toBe('Connection failed'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.closeCode).toBe(1006); + expect(result.value.reason).toBe('Connection failed'); + } }); - it('should reject when error occurs without timely close event', async () => { + it('should return error Result when error occurs without timely close event', async () => { const { sleep } = await import('../room/utils'); vi.mocked(sleep).mockResolvedValue(undefined); @@ -256,9 +281,14 @@ describe('WebSocketStream', () => { mockWebSocket.triggerOpen(); mockWebSocket.triggerError(); - await expect(wsStream.closed).rejects.toThrow( - 'Encountered unspecified websocket error without a timely close event', - ); + const result = await wsStream.closed; + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.reason).toBe(ConnectionErrorReason.WebSocket); + expect(result.error.message).toBe( + 'Encountered unspecified websocket error without a timely close event', + ); + } }); }); @@ -267,8 +297,11 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const result = await wsStream.opened; + expect(result.isOk()).toBe(true); + if (!result.isOk()) return; + const connection = result.value; const reader = connection.readable.getReader(); const message1 = new ArrayBuffer(8); @@ -292,23 +325,22 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); mockWebSocket.triggerError(); - await Promise.all([ - expect(reader.read()).rejects.toBeDefined(), - expect(wsStream.closed).rejects.toBeDefined(), - ]); + const closedResult = await wsStream.closed; + await expect(reader.read()).rejects.toBeDefined(); + expect(closedResult.isErr()).toBe(true); }); it('should close WebSocket with custom close info when cancelled', async () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); const closeSpy = vi.spyOn(mockWebSocket, 'close'); @@ -322,7 +354,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader1 = connection.readable.getReader(); @@ -337,7 +369,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const writer = connection.writable.getWriter(); const sendSpy = vi.spyOn(mockWebSocket, 'send'); @@ -362,7 +394,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const writer = connection.writable.getWriter(); const closeSpy = vi.spyOn(mockWebSocket, 'close'); @@ -376,7 +408,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const writer = connection.writable.getWriter(); @@ -418,7 +450,7 @@ describe('WebSocketStream', () => { }); mockWebSocket.triggerOpen(); - await wsStream.opened; + await getConnectionOrFail(wsStream); const closeSpy = vi.spyOn(mockWebSocket, 'close'); @@ -433,7 +465,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); @@ -467,7 +499,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); const writer = connection.writable.getWriter(); @@ -493,7 +525,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const sourceData = [new ArrayBuffer(8), new ArrayBuffer(16), new ArrayBuffer(32)]; let dataIndex = 0; @@ -524,7 +556,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const msg1 = new ArrayBuffer(8); const msg2 = new ArrayBuffer(16); @@ -552,7 +584,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); @@ -562,17 +594,16 @@ describe('WebSocketStream', () => { // Trigger error while read is pending mockWebSocket.triggerError(); - await Promise.all([ - expect(readPromise).rejects.toBeDefined(), - expect(wsStream.closed).rejects.toBeDefined(), - ]); + const closedResult = await wsStream.closed; + await expect(readPromise).rejects.toBeDefined(); + expect(closedResult.isErr()).toBe(true); }); it('should support zero-length and empty messages', async () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); const writer = connection.writable.getWriter(); @@ -599,7 +630,7 @@ describe('WebSocketStream', () => { const wsStream = new WebSocketStream('wss://test.example.com'); mockWebSocket.triggerOpen(); - const connection = await wsStream.opened; + const connection = await getConnectionOrFail(wsStream); const reader = connection.readable.getReader(); diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index d930c212b9..6e25c223f7 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -1,4 +1,6 @@ // https://github.com/CarterLi/websocketstream-polyfill +import { ResultAsync } from 'neverthrow'; +import { ConnectionError } from '../room/errors'; import { sleep } from '../room/utils'; export interface WebSocketConnection { @@ -18,6 +20,8 @@ export interface WebSocketStreamOptions { signal?: AbortSignal; } +export type WebSocketError = ReturnType; + /** * [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) with [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) * @@ -26,11 +30,11 @@ export interface WebSocketStreamOptions { export class WebSocketStream { readonly url: string; - readonly opened: Promise>; + readonly opened: ResultAsync, WebSocketError>; - readonly closed: Promise; + readonly closed: ResultAsync; - readonly close: (closeInfo?: WebSocketCloseInfo) => void; + readonly close!: (closeInfo?: WebSocketCloseInfo) => void; get readyState(): number { return this.ws.readyState; @@ -40,77 +44,120 @@ export class WebSocketStream ws.close(code, reason); - this.opened = new Promise((resolve, reject) => { - ws.onopen = () => { - resolve({ - readable: new ReadableStream({ - start(controller) { - ws.onmessage = ({ data }) => controller.enqueue(data); - ws.onerror = (e) => controller.error(e); - }, - cancel: closeWithInfo, - }), - writable: new WritableStream({ - write(chunk) { - ws.send(chunk); - }, - abort() { - ws.close(); - }, - close: closeWithInfo, - }), - protocol: ws.protocol, - extensions: ws.extensions, - }); - ws.removeEventListener('error', reject); - }; - ws.addEventListener('error', reject); - }); - - this.closed = new Promise((resolve, reject) => { - const rejectHandler = async () => { - const closePromise = new Promise((res) => { - if (ws.readyState === WebSocket.CLOSED) return; - else { - ws.addEventListener( - 'close', - (closeEv: CloseEvent) => { - res(closeEv); + // eslint-disable-next-line neverthrow-must-use/must-use-result + this.opened = ResultAsync.fromPromise, WebSocketError>( + new Promise((resolve, r) => { + const reject = (err: WebSocketError) => r(err); + const errorHandler = (e: Event) => { + console.error(e); + reject( + ConnectionError.websocket('Encountered websocket error while establishing connection'), + ); + ws.removeEventListener('open', openHandler); + }; + + const onCloseDuringOpen = (ev: CloseEvent) => { + reject( + ConnectionError.websocket( + `WS closed during connection establishment: ${ev.reason}`, + ev.code, + ev.reason, + ), + ); + }; + + const openHandler = () => { + resolve({ + readable: new ReadableStream({ + start(controller) { + ws.onmessage = ({ data }) => controller.enqueue(data); + ws.onerror = (e) => controller.error(e); }, - { once: true }, + cancel: closeWithInfo, + }), + writable: new WritableStream({ + write(chunk) { + ws.send(chunk); + }, + abort() { + ws.close(); + }, + close: closeWithInfo, + }), + protocol: ws.protocol, + extensions: ws.extensions, + }); + ws.removeEventListener('error', errorHandler); + ws.removeEventListener('close', onCloseDuringOpen); + }; + + console.log('websocket setup registering event listeners'); + + ws.addEventListener('open', openHandler, { once: true }); + ws.addEventListener('error', errorHandler, { once: true }); + ws.addEventListener('close', onCloseDuringOpen, { once: true }); + }), + (error) => error as WebSocketError, + ); + + // eslint-disable-next-line neverthrow-must-use/must-use-result + this.closed = ResultAsync.fromPromise( + new Promise((resolve, r) => { + const reject = (err: WebSocketError) => r(err); + const errorHandler = async () => { + const closePromise = new Promise((res) => { + if (ws.readyState === WebSocket.CLOSED) return; + else { + ws.addEventListener( + 'close', + (closeEv: CloseEvent) => { + res(closeEv); + }, + { once: true }, + ); + } + }); + const reason = await Promise.race([sleep(250), closePromise]); + if (!reason) { + reject( + ConnectionError.websocket( + 'Encountered unspecified websocket error without a timely close event', + ), ); + } else { + // if we can infer the close reason from the close event then resolve with ok, we don't need to throw + resolve({ closeCode: reason.code, reason: reason.reason }); } - }); - const reason = await Promise.race([sleep(250), closePromise]); - if (!reason) { - reject(new Error('Encountered unspecified websocket error without a timely close event')); - } else { - // if we can infer the close reason from the close event then resolve the promise, we don't need to throw - resolve(reason); + }; + + if (ws.readyState === WebSocket.CLOSED) { + reject(ConnectionError.websocket('Websocket already closed at initialization time')); + return; } - }; - ws.onclose = ({ code, reason }) => { - resolve({ closeCode: code, reason }); - ws.removeEventListener('error', rejectHandler); - }; - ws.addEventListener('error', rejectHandler); - }); + ws.onclose = ({ code, reason }) => { + resolve({ closeCode: code, reason }); + ws.removeEventListener('error', errorHandler); + }; + + ws.addEventListener('error', errorHandler); + }), + (error) => error as WebSocketError, + ); if (options.signal) { - options.signal.onabort = () => ws.close(); + options.signal.onabort = () => ws.close(undefined, 'AbortSignal triggered'); } this.close = closeWithInfo; diff --git a/src/api/utils.ts b/src/api/utils.ts index 3fb538a9a4..74666588f3 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,4 +1,7 @@ import { SignalResponse } from '@livekit/protocol'; +import { Result, ResultAsync, errAsync } from 'neverthrow'; +import type { Mutex } from '@livekit/mutex'; +import { ConnectionError } from '../room/errors'; import { toHttpUrl, toWebsocketUrl } from '../room/utils'; export function createRtcUrl(url: string, searchParams: URLSearchParams) { @@ -49,3 +52,128 @@ export function getAbortReasonAsString( return 'toString' in reason ? reason.toString() : defaultMessage; } } + +export function withTimeout( + ra: ResultAsyncLike, + ms: number, +): ResultAsync> { + const timeout = ResultAsync.fromPromise( + new Promise((_, reject) => + setTimeout(() => { + reject(ConnectionError.timeout('Timeout')); + }, ms), + ), + (e) => e as ReturnType, + ); + + return raceResults([ra, timeout]); +} + +export function withAbort( + ra: ResultAsyncLike, + signal: AbortSignal | undefined, +): ResultAsync> { + if (signal?.aborted) { + return errAsync(ConnectionError.cancelled('AbortSignal invoked')); + } + + const abortResult = ResultAsync.fromPromise( + new Promise((_, reject) => { + const onAbortHandler = () => { + reject(ConnectionError.cancelled('AbortSignal invoked')); + }; + signal?.addEventListener('abort', onAbortHandler, { once: true }); + }), + (e) => e as ReturnType, + ); + + return raceResults([ra, abortResult]); +} + +export function withMutex( + fn: ResultAsyncLike, + mutex: Mutex, +): ResultAsync { + return ResultAsync.fromSafePromise(mutex.lock()).andThen((unlock) => withFinally(fn, unlock)); +} + +/** + * Executes a callback after a ResultAsync completes, regardless of success or failure. + * Similar to Promise.finally() but for ResultAsync. + * + * @param ra - The ResultAsync to execute + * @param onFinally - Callback to run after completion (receives no arguments) + * @returns A new ResultAsync with the same result, but runs onFinally first + * + * @example + * ```ts + * withFinally( + * someOperation(), + * () => cleanup() + * ) + * ``` + */ +export function withFinally( + ra: ResultAsyncLike, + onFinally: () => void | Promise, +): ResultAsync { + return ResultAsync.fromPromise( + (async () => { + try { + const result = await ra; + return result.match( + (value) => value, + (error) => { + throw error as Error; + }, + ); + } catch (error) { + throw error as Error; + } finally { + await onFinally(); + } + })(), + (e) => e as E, + ); +} + +/** + * Races multiple ResultAsync operations and returns whichever completes first. + * If all fail, returns the error from the first one to reject. + * API-compatible with Promise.race, supporting heterogeneous types. + * + * @param values - Array of ResultAsync operations to race (can have different types) + * @returns A new ResultAsync with the result of whichever completes first + * + * @example + * ```ts + * // Race a connection attempt against a timeout + * raceResults([ + * connectToServer(), // ResultAsync + * delay(5000).andThen(() => errAsync(new TimeoutError())) // ResultAsync + * ]) // ResultAsync + * ``` + */ +export function raceResults[]>( + values: T, +): ResultAsync< + T[number] extends ResultAsync ? V : never, + T[number] extends ResultAsync ? E : never +> { + type Value = T[number] extends ResultAsync ? V : never; + type Err = T[number] extends ResultAsync ? E : never; + + const settledPromises = values.map( + (ra): PromiseLike => + ra.then((res) => + res.match( + (v) => Promise.resolve(v), + (err) => Promise.reject(err), + ), + ), + ); + + return ResultAsync.fromPromise(Promise.race(settledPromises), (e) => e as Err); +} + +export type ResultAsyncLike = ResultAsync | Promise>; diff --git a/src/connectionHelper/checks/turn.ts b/src/connectionHelper/checks/turn.ts index 3aef7f05e1..4eabb6fa22 100644 --- a/src/connectionHelper/checks/turn.ts +++ b/src/connectionHelper/checks/turn.ts @@ -8,7 +8,7 @@ export class TURNCheck extends Checker { async perform(): Promise { const signalClient = new SignalClient(); - const joinRes = await signalClient.join(this.url, this.token, { + const joinResult = await signalClient.join(this.url, this.token, { autoSubscribe: true, maxRetries: 0, e2eeEnabled: false, @@ -16,6 +16,9 @@ export class TURNCheck extends Checker { singlePeerConnection: false, }); + // TODO fix unsafe usage + const joinRes = joinResult._unsafeUnwrap(); + let hasTLS = false; let hasTURN = false; let hasSTUN = false; diff --git a/src/connectionHelper/checks/websocket.ts b/src/connectionHelper/checks/websocket.ts index ab9afa34d3..40592e4617 100644 --- a/src/connectionHelper/checks/websocket.ts +++ b/src/connectionHelper/checks/websocket.ts @@ -13,13 +13,15 @@ export class WebSocketCheck extends Checker { } let signalClient = new SignalClient(); - const joinRes = await signalClient.join(this.url, this.token, { - autoSubscribe: true, - maxRetries: 0, - e2eeEnabled: false, - websocketTimeout: 15_000, - singlePeerConnection: false, - }); + const joinRes = ( + await signalClient.join(this.url, this.token, { + autoSubscribe: true, + maxRetries: 0, + e2eeEnabled: false, + websocketTimeout: 15_000, + singlePeerConnection: false, + }) + )._unsafeUnwrap(); this.appendMessage(`Connected to server, version ${joinRes.serverVersion}.`); if (joinRes.serverInfo?.edition === ServerInfo_Edition.Cloud && joinRes.serverInfo?.region) { this.appendMessage(`LiveKit Cloud: ${joinRes.serverInfo?.region}`); diff --git a/src/e2ee/E2eeManager.ts b/src/e2ee/E2eeManager.ts index b10676a891..8ef26dacfe 100644 --- a/src/e2ee/E2eeManager.ts +++ b/src/e2ee/E2eeManager.ts @@ -227,7 +227,7 @@ export class E2EEManager }; private onWorkerError = (ev: ErrorEvent) => { - log.error('e2ee worker encountered an error:', { error: ev.error }); + log.error('e2ee worker encountered an error:', { error: ev }); this.emit(EncryptionEvent.EncryptionError, ev.error, undefined); }; diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index c84ae8e965..14c0591d9f 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -67,7 +67,7 @@ export default class PCTransport extends EventEmitter { remoteNackMids: string[] = []; - onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void; + onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => Promise; onIceCandidate?: (candidate: RTCIceCandidate) => void; @@ -352,7 +352,7 @@ export default class PCTransport extends EventEmitter { return; } await this.setMungedSDP(offer, write(sdpParsed)); - this.onOffer(offer, this.latestOfferId); + await this.onOffer(offer, this.latestOfferId); } finally { unlock(); } diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index e805de8ed2..ee9d8e3982 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -3,7 +3,7 @@ import { SignalTarget } from '@livekit/protocol'; import log, { LoggerNames, getLogger } from '../logger'; import PCTransport, { PCEvents } from './PCTransport'; import { roomConnectOptionDefaults } from './defaults'; -import { ConnectionError, ConnectionErrorReason } from './errors'; +import { ConnectionError } from './errors'; import CriticalTimers from './timers'; import type { LoggerOptions } from './types'; import { sleep } from './utils'; @@ -49,7 +49,7 @@ export class PCTransportManager { public onTrack?: (ev: RTCTrackEvent) => void; - public onPublisherOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void; + public onPublisherOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => Promise; private isPublisherConnectionRequired: boolean; @@ -99,8 +99,8 @@ export class PCTransportManager { this.onTrack?.(ev); }; - this.publisher.onOffer = (offer, offerId) => { - this.onPublisherOffer?.(offer, offerId); + this.publisher.onOffer = async (offer, offerId) => { + return this.onPublisherOffer?.(offer, offerId); }; this.state = PCTransportState.NEW; @@ -345,12 +345,7 @@ export class PCTransportManager { this.log.warn('abort transport connection', this.logContext); CriticalTimers.clearTimeout(connectTimeout); - reject( - new ConnectionError( - 'room connection has been cancelled', - ConnectionErrorReason.Cancelled, - ), - ); + reject(ConnectionError.cancelled('room connection has been cancelled')); }; if (abortController?.signal.aborted) { abortHandler(); @@ -359,23 +354,13 @@ export class PCTransportManager { const connectTimeout = CriticalTimers.setTimeout(() => { abortController?.signal.removeEventListener('abort', abortHandler); - reject( - new ConnectionError( - 'could not establish pc connection', - ConnectionErrorReason.InternalError, - ), - ); + reject(ConnectionError.internal('could not establish pc connection')); }, timeout); while (this.state !== PCTransportState.CONNECTED) { await sleep(50); // FIXME we shouldn't rely on `sleep` in the connection paths, as it invokes `setTimeout` which can be drastically throttled by browser implementations if (abortController?.signal.aborted) { - reject( - new ConnectionError( - 'room connection has been cancelled', - ConnectionErrorReason.Cancelled, - ), - ); + reject(ConnectionError.cancelled('room connection has been cancelled')); return; } } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index c440928a10..cb3ce668d1 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -39,6 +39,7 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; +import { type Result, err, errAsync, ok, safeTry } from 'neverthrow'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; @@ -62,6 +63,7 @@ import { ConnectionError, ConnectionErrorReason, NegotiationError, + SimulatedError, TrackInvalidError, UnexpectedConnectionState, } from './errors'; @@ -264,38 +266,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit token: string, opts: SignalOptions, abortSignal?: AbortSignal, - ): Promise { + ): Promise> { this.url = url; this.token = token; this.signalOpts = opts; this.maxJoinAttempts = opts.maxRetries; - try { - this.joinAttempts += 1; - - this.setupSignalClientCallbacks(); - const joinResponse = await this.client.join(url, token, opts, abortSignal); - this._isClosed = false; - this.latestJoinResponse = joinResponse; + this.joinAttempts += 1; - this.subscriberPrimary = joinResponse.subscriberPrimary; - if (!this.pcManager) { - await this.configure(joinResponse); - } - - // create offer - if (!this.subscriberPrimary || joinResponse.fastPublish) { - this.negotiate().catch((err) => { - log.error(err, this.logContext); - }); - } + this.setupSignalClientCallbacks(); + const joinResult = await this.client.join(url, token, opts, abortSignal); - this.registerOnLineListener(); - this.clientConfiguration = joinResponse.clientConfiguration; - this.emit(EngineEvent.SignalConnected, joinResponse); - return joinResponse; - } catch (e) { - if (e instanceof ConnectionError) { - if (e.reason === ConnectionErrorReason.ServerUnreachable) { + if (joinResult.isErr()) { + const error = joinResult.error; + if (error instanceof ConnectionError) { + if (error.reason === ConnectionErrorReason.ServerUnreachable) { this.log.warn( `Couldn't connect to server, attempt ${this.joinAttempts} of ${this.maxJoinAttempts}`, this.logContext, @@ -305,8 +289,30 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } } - throw e; + return err(error); + } + + const joinResponse = joinResult.value; + + this._isClosed = false; + this.latestJoinResponse = joinResponse; + + this.subscriberPrimary = joinResponse.subscriberPrimary; + if (!this.pcManager) { + await this.configure(joinResponse); } + + // create offer + if (!this.subscriberPrimary || joinResponse.fastPublish) { + this.negotiate().catch((error) => { + log.error(error, this.logContext); + }); + } + + this.registerOnLineListener(); + this.clientConfiguration = joinResponse.clientConfiguration; + this.emit(EngineEvent.SignalConnected, joinResponse); + return ok(joinResponse); } async close() { @@ -381,10 +387,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const publicationTimeout = setTimeout(() => { delete this.pendingTrackResolvers[req.cid]; reject( - new ConnectionError( - 'publication of local track timed out, no response from server', - ConnectionErrorReason.Timeout, - ), + ConnectionError.timeout('publication of local track timed out, no response from server'), ); }, 10_000); this.pendingTrackResolvers[req.cid] = { @@ -468,7 +471,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit }; this.pcManager.onPublisherOffer = (offer, offerId) => { - this.client.sendOffer(offer, offerId); + return this.client.sendOffer(offer, offerId); }; this.pcManager.onDataChannel = this.handleDataChannel; @@ -1021,23 +1024,26 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.fullReconnectOnNext = true; } - try { - this.attemptingReconnect = true; - if (this.fullReconnectOnNext) { - await this.restartConnection(); - } else { - await this.resumeConnection(reason); - } - this.clearPendingReconnect(); - this.fullReconnectOnNext = false; - } catch (e) { + let result: Result; + this.attemptingReconnect = true; + if (this.fullReconnectOnNext) { + result = await this.restartConnection(); + } else { + result = await this.resumeConnection(reason); + } + this.clearPendingReconnect(); + this.fullReconnectOnNext = false; + if (result.isErr()) { + const error = result.error; this.reconnectAttempts += 1; let recoverable = true; - if (e instanceof UnexpectedConnectionState) { - this.log.debug('received unrecoverable error', { ...this.logContext, error: e }); + // TODO this needs proper handling to define which errors are actually unexpected and non recoverable + // Currently all connection related errors are ConnectionErrors + if (error instanceof UnexpectedConnectionState) { + this.log.debug('received unrecoverable error', { ...this.logContext, error }); // unrecoverable recoverable = false; - } else if (!(e instanceof SignalReconnectError)) { + } else if (!(error instanceof SignalReconnectError)) { // cannot resume this.fullReconnectOnNext = true; } @@ -1054,9 +1060,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.emit(EngineEvent.Disconnected); await this.close(); } - } finally { - this.attemptingReconnect = false; } + this.attemptingReconnect = false; } private getNextRetryDelay(context: ReconnectContext) { @@ -1070,108 +1075,114 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return null; } - private async restartConnection(regionUrl?: string) { - try { - if (!this.url || !this.token) { + private async restartConnection( + regionUrl?: string, + ): Promise> { + const self = this; + const restartResultAsync = safeTry(async function* () { + if (!self.url || !self.token) { // permanent failure, don't attempt reconnection - throw new UnexpectedConnectionState('could not reconnect, url or token not saved'); + return err(new UnexpectedConnectionState('could not reconnect, url or token not saved')); } - this.log.info(`reconnecting, attempt: ${this.reconnectAttempts}`, this.logContext); - this.emit(EngineEvent.Restarting); + self.log.info(`reconnecting, attempt: ${self.reconnectAttempts}`, self.logContext); + self.emit(EngineEvent.Restarting); - if (!this.client.isDisconnected) { - await this.client.sendLeave(); + if (!self.client.isDisconnected) { + await self.client.sendLeave(); } - await this.cleanupPeerConnections(); - await this.cleanupClient(); + await self.cleanupPeerConnections(); + await self.cleanupClient(); - let joinResponse: JoinResponse; - try { - if (!this.signalOpts) { - this.log.warn( - 'attempted connection restart, without signal options present', - this.logContext, - ); - throw new SignalReconnectError(); - } - // in case a regionUrl is passed, the region URL takes precedence - joinResponse = await this.join(regionUrl ?? this.url, this.token, this.signalOpts); - } catch (e) { - if (e instanceof ConnectionError && e.reason === ConnectionErrorReason.NotAllowed) { - throw new UnexpectedConnectionState('could not reconnect, token might be expired'); + if (!self.signalOpts) { + self.log.warn( + 'attempted connection restart, without signal options present', + self.logContext, + ); + return err(new SignalReconnectError()); + } + // in case a regionUrl is passed, the region URL takes precedence + const joinResult = await self.join(regionUrl ?? self.url, self.token, self.signalOpts); + if (joinResult.isErr()) { + const error = joinResult.error; + if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.NotAllowed) { + return err(new UnexpectedConnectionState('could not reconnect, token might be expired')); } - throw new SignalReconnectError(); + return err(new SignalReconnectError()); } - if (this.shouldFailNext) { - this.shouldFailNext = false; - throw new Error('simulated failure'); + if (self.shouldFailNext) { + self.shouldFailNext = false; + return err(new SimulatedError()); } - this.client.setReconnected(); - this.emit(EngineEvent.SignalRestarted, joinResponse); + self.client.setReconnected(); + self.emit(EngineEvent.SignalRestarted, joinResult.value); - await this.waitForPCReconnected(); + await self.waitForPCReconnected(); // re-check signal connection state before setting engine as resumed - if (this.client.currentState !== SignalConnectionState.CONNECTED) { - throw new SignalReconnectError('Signal connection got severed during reconnect'); + if (self.client.currentState !== SignalConnectionState.CONNECTED) { + return err(new SignalReconnectError('Signal connection got severed during reconnect')); } - this.regionUrlProvider?.resetAttempts(); + self.regionUrlProvider?.resetAttempts(); // reconnect success - this.emit(EngineEvent.Restarted); - } catch (error) { + self.emit(EngineEvent.Restarted); + return ok(); + }); + + const restartResult = await restartResultAsync; + + if (restartResult.isErr()) { const nextRegionUrl = await this.regionUrlProvider?.getNextBestRegionUrl(); if (nextRegionUrl) { - await this.restartConnection(nextRegionUrl); - return; + this.log.info('retrying signal connection'); + return this.restartConnection(nextRegionUrl); } else { // no more regions to try (or we're not on cloud) this.regionUrlProvider?.resetAttempts(); - throw error; + return err(restartResult.error); } } + return ok(restartResult.value); } - private async resumeConnection(reason?: ReconnectReason): Promise { + private async resumeConnection( + reason?: ReconnectReason, + ): Promise> { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection - throw new UnexpectedConnectionState('could not reconnect, url or token not saved'); + return errAsync(new UnexpectedConnectionState('could not reconnect, url or token not saved')); } // trigger publisher reconnect if (!this.pcManager) { - throw new UnexpectedConnectionState('publisher and subscriber connections unset'); + return errAsync(new UnexpectedConnectionState('publisher and subscriber connections unset')); } this.log.info(`resuming signal connection, attempt ${this.reconnectAttempts}`, this.logContext); this.emit(EngineEvent.Resuming); - let res: ReconnectResponse | undefined; - try { - this.setupSignalClientCallbacks(); - res = await this.client.reconnect(this.url, this.token, this.participantSid, reason); - } catch (error) { - let message = ''; - if (error instanceof Error) { - message = error.message; - this.log.error(error.message, { ...this.logContext, error }); - } - if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.NotAllowed) { - throw new UnexpectedConnectionState('could not reconnect, token might be expired'); - } - if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.LeaveRequest) { - throw error; - } - throw new SignalReconnectError(message); + this.setupSignalClientCallbacks(); + + const reconnectResult = await this.client.reconnect( + this.url, + this.token, + this.participantSid, + reason, + ); + + if (reconnectResult.isErr()) { + return err(reconnectResult.error); } + this.emit(EngineEvent.SignalResumed); - if (res) { - const rtcConfig = this.makeRTCConfiguration(res); + const reconnectResponse = reconnectResult.value; + if (reconnectResponse) { + const rtcConfig = this.makeRTCConfiguration(reconnectResponse); this.pcManager.updateConfiguration(rtcConfig); if (this.latestJoinResponse) { - this.latestJoinResponse.serverInfo = res.serverInfo; + this.latestJoinResponse.serverInfo = reconnectResponse.serverInfo; } } else { this.log.warn('Did not receive reconnect response', this.logContext); @@ -1179,7 +1190,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (this.shouldFailNext) { this.shouldFailNext = false; - throw new Error('simulated failure'); + return err(new SimulatedError()); } await this.pcManager.triggerIceRestart(); @@ -1188,7 +1199,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // re-check signal connection state before setting engine as resumed if (this.client.currentState !== SignalConnectionState.CONNECTED) { - throw new SignalReconnectError('Signal connection got severed during reconnect'); + return err(new SignalReconnectError('Signal connection got severed during reconnect')); } this.client.setReconnected(); @@ -1199,12 +1210,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.createDataChannels(); } - if (res?.lastMessageSeq) { - this.resendReliableMessagesForResume(res.lastMessageSeq); + if (reconnectResponse?.lastMessageSeq) { + this.resendReliableMessagesForResume(reconnectResponse.lastMessageSeq); } // resume success this.emit(EngineEvent.Resumed); + + return ok(); } async waitForPCInitialConnection(timeout?: number, abortController?: AbortController) { @@ -1228,10 +1241,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } catch (e: any) { // TODO do we need a `failed` state here for the PC? this.pcState = PCState.Disconnected; - throw new ConnectionError( - `could not establish PC connection, ${e.message}`, - ConnectionErrorReason.InternalError, - ); + throw ConnectionError.internal(`could not establish PC connection: ${e.message}`); } } @@ -1412,10 +1422,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const transport = subscriber ? this.pcManager.subscriber : this.pcManager.publisher; const transportName = subscriber ? 'Subscriber' : 'Publisher'; if (!transport) { - throw new ConnectionError( - `${transportName} connection not set`, - ConnectionErrorReason.InternalError, - ); + throw ConnectionError.internal(`${transportName} connection not set`); } let needNegotiation = false; @@ -1434,8 +1441,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } if (needNegotiation) { // start negotiation - this.negotiate().catch((err) => { - log.error(err, this.logContext); + this.negotiate().catch((error) => { + log.error(error, this.logContext); }); } @@ -1456,9 +1463,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit await sleep(50); } - throw new ConnectionError( + throw ConnectionError.internal( `could not establish ${transportName} connection, state: ${transport.getICEConnectionState()}`, - ConnectionErrorReason.InternalError, ); } diff --git a/src/room/RegionUrlProvider.test.ts b/src/room/RegionUrlProvider.test.ts index 0d027c476b..bb27a72d62 100644 --- a/src/room/RegionUrlProvider.test.ts +++ b/src/room/RegionUrlProvider.test.ts @@ -180,7 +180,9 @@ describe('RegionUrlProvider', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(401)); - await expect(provider.fetchRegionSettings()).rejects.toThrow(ConnectionError); + await expect(provider.fetchRegionSettings()).rejects.toThrow( + ConnectionError.notAllowed('Could not fetch region settings: Unauthorized', 401), + ); await expect(provider.fetchRegionSettings()).rejects.toMatchObject({ reason: ConnectionErrorReason.NotAllowed, status: 401, @@ -191,10 +193,14 @@ describe('RegionUrlProvider', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(500)); - await expect(provider.fetchRegionSettings()).rejects.toThrow(ConnectionError); + await expect(provider.fetchRegionSettings()).rejects.toThrow( + ConnectionError.internal('Could not fetch region settings: Internal Server Error', { + status: 500, + }), + ); await expect(provider.fetchRegionSettings()).rejects.toMatchObject({ reason: ConnectionErrorReason.InternalError, - status: 500, + context: { status: 500 }, }); }); diff --git a/src/room/RegionUrlProvider.ts b/src/room/RegionUrlProvider.ts index dbd578f928..a3d4f6fe2f 100644 --- a/src/room/RegionUrlProvider.ts +++ b/src/room/RegionUrlProvider.ts @@ -45,25 +45,26 @@ export class RegionUrlProvider { const regionSettings = (await regionSettingsResponse.json()) as RegionSettings; return { regionSettings, updatedAtInMs: Date.now(), maxAgeInMs }; } else { - throw new ConnectionError( - `Could not fetch region settings: ${regionSettingsResponse.statusText}`, - regionSettingsResponse.status === 401 - ? ConnectionErrorReason.NotAllowed - : ConnectionErrorReason.InternalError, - regionSettingsResponse.status, - ); + throw regionSettingsResponse.status === 401 + ? ConnectionError.notAllowed( + `Could not fetch region settings: ${regionSettingsResponse.statusText}`, + regionSettingsResponse.status, + ) + : ConnectionError.internal( + `Could not fetch region settings: ${regionSettingsResponse.statusText}`, + { status: regionSettingsResponse.status }, + ); } } catch (e: unknown) { if (e instanceof ConnectionError) { // rethrow connection errors throw e; } else if (signal?.aborted) { - throw new ConnectionError(`Region fetching was aborted`, ConnectionErrorReason.Cancelled); + throw ConnectionError.cancelled(`Region fetching was aborted`); } else { // wrap other errors as connection errors (e.g. timeouts) - throw new ConnectionError( + throw ConnectionError.serverUnreachable( `Could not fetch region settings, ${e instanceof Error ? `${e.name}: ${e.message}` : e}`, - ConnectionErrorReason.ServerUnreachable, 500, // using 500 as a catch-all manually set error code here ); } diff --git a/src/room/Room.ts b/src/room/Room.ts index 9257a1c542..100b53bedb 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -686,7 +686,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) try { await BackOffStrategy.getInstance().getBackOffPromise(url); if (abortController.signal.aborted) { - throw new ConnectionError('Connection attempt aborted', ConnectionErrorReason.Cancelled); + ConnectionError.cancelled('Connection attempt aborted'); } await this.attemptConnection(regionUrl ?? url, token, opts, abortController); this.abortController = undefined; @@ -773,7 +773,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) roomOptions: InternalRoomOptions, abortController: AbortController, ): Promise => { - const joinResponse = await engine.join( + const joinResult = await engine.join( url, token, { @@ -788,6 +788,13 @@ class Room extends (EventEmitter as new () => TypedEmitter) abortController.signal, ); + // TODO continue propagating Result, we don't need to throw here + if (joinResult.isErr()) { + throw joinResult.error; + } + + const joinResponse = joinResult.value; + let serverInfo: Partial | undefined = joinResponse.serverInfo; if (!serverInfo) { serverInfo = { version: joinResponse.serverVersion, region: joinResponse.serverRegion }; @@ -894,12 +901,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) } catch (err) { await this.engine.close(); this.recreateEngine(); - const resultingError = new ConnectionError( - `could not establish signal connection`, - abortController.signal.aborted - ? ConnectionErrorReason.Cancelled - : ConnectionErrorReason.ServerUnreachable, - ); + const resultingError = abortController.signal.aborted + ? ConnectionError.cancelled(`could not establish signal connection`) + : ConnectionError.serverUnreachable(`could not establish signal connection`); if (err instanceof Error) { resultingError.message = `${resultingError.message}: ${err.message}`; } @@ -917,7 +921,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (abortController.signal.aborted) { await this.engine.close(); this.recreateEngine(); - throw new ConnectionError(`Connection attempt aborted`, ConnectionErrorReason.Cancelled); + throw ConnectionError.cancelled(`Connection attempt aborted`); } try { @@ -974,9 +978,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.log.warn(msg, this.logContext); this.abortController?.abort(msg); // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly - this.connectFuture?.reject?.( - new ConnectionError('Client initiated disconnect', ConnectionErrorReason.Cancelled), - ); + this.connectFuture?.reject?.(ConnectionError.cancelled('Client initiated disconnect')); this.connectFuture = undefined; } @@ -1925,7 +1927,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) }); if (byteLength(response) > MAX_PAYLOAD_BYTES) { responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); - console.warn(`RPC Response payload too large for ${method}`); + this.log.warn(`RPC Response payload too large for ${method}`, this.logContext); } else { responsePayload = response; } @@ -1933,9 +1935,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (error instanceof RpcError) { responseError = error; } else { - console.warn( + this.log.warn( `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, - error, + { ...this.logContext, error }, ); responseError = RpcError.builtIn('APPLICATION_ERROR'); } diff --git a/src/room/errors.ts b/src/room/errors.ts index 5c4c842aab..455859bba5 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -10,6 +10,14 @@ export class LivekitError extends Error { } } +export class SimulatedError extends LivekitError { + readonly type = 'simulated'; + + constructor(message = 'Simulated failure') { + super(-1, message); + } +} + export enum ConnectionErrorReason { NotAllowed, ServerUnreachable, @@ -17,30 +25,132 @@ export enum ConnectionErrorReason { Cancelled, LeaveRequest, Timeout, + WebSocket, } -export class ConnectionError extends LivekitError { +type NotAllowed = { + reason: ConnectionErrorReason.NotAllowed; + status: number; + context?: unknown; +}; + +type InternalError = { + reason: ConnectionErrorReason.InternalError; + status: never; + context?: { status?: number; statusText?: string }; +}; + +type ConnectionTimeout = { + reason: ConnectionErrorReason.Timeout; + status: never; + context: never; +}; + +type LeaveRequest = { + reason: ConnectionErrorReason.LeaveRequest; + status: never; + context: DisconnectReason; +}; + +type Cancelled = { + reason: ConnectionErrorReason.Cancelled; + status: never; + context: never; +}; + +type ServerUnreachable = { + reason: ConnectionErrorReason.ServerUnreachable; + status?: number; + context?: never; +}; + +type WebSocket = { + reason: ConnectionErrorReason.WebSocket; status?: number; + context?: string; +}; + +type ConnectionErrorVariants = + | NotAllowed + | ConnectionTimeout + | LeaveRequest + | InternalError + | Cancelled + | ServerUnreachable + | WebSocket; + +export class ConnectionError< + Variant extends ConnectionErrorVariants = ConnectionErrorVariants, +> extends LivekitError { + status?: Variant['status']; - context?: unknown | DisconnectReason; + context: Variant['context']; - reason: ConnectionErrorReason; + reason: Variant['reason']; reasonName: string; - constructor( + readonly name = 'ConnectionError'; + + protected constructor( message: string, - reason: ConnectionErrorReason, - status?: number, - context?: unknown | DisconnectReason, + reason: Variant['reason'], + status?: Variant['status'], + context?: Variant['context'], ) { super(1, message); - this.name = 'ConnectionError'; this.status = status; this.reason = reason; this.context = context; this.reasonName = ConnectionErrorReason[reason]; } + + static notAllowed(message: string, status: number, context?: unknown) { + return new ConnectionError( + message, + ConnectionErrorReason.NotAllowed, + status, + context, + ); + } + + static timeout(message: string) { + return new ConnectionError(message, ConnectionErrorReason.Timeout); + } + + static leaveRequest(message: string, context: DisconnectReason) { + return new ConnectionError( + message, + ConnectionErrorReason.LeaveRequest, + undefined, + context, + ); + } + + static internal(message: string, context?: { status?: number; statusText?: string }) { + return new ConnectionError( + message, + ConnectionErrorReason.InternalError, + undefined, + context, + ); + } + + static cancelled(message: string) { + return new ConnectionError(message, ConnectionErrorReason.Cancelled); + } + + static serverUnreachable(message: string, status?: number) { + return new ConnectionError( + message, + ConnectionErrorReason.ServerUnreachable, + status, + ); + } + + static websocket(message: string, status?: number, reason?: string) { + return new ConnectionError(message, ConnectionErrorReason.WebSocket, status, reason); + } } export class DeviceUnsupportedError extends LivekitError { From bb845310310f2e7d9e2f349fffa4aed18c97eedd Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 21 Nov 2025 15:21:08 +0100 Subject: [PATCH 57/58] error propagation fixes --- src/api/SignalClient.ts | 3 -- src/room/PCTransportManager.ts | 69 +++++++++++++++++++--------------- src/room/RTCEngine.ts | 26 +++++++++---- src/room/Room.ts | 7 +++- 4 files changed, 62 insertions(+), 43 deletions(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index b3aec8d7a7..43a355431f 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -986,9 +986,6 @@ export class SignalClient { ); } } catch (e) { - if (!(e instanceof ConnectionError)) { - console.warn('received unexpected error', e); - } return err( e instanceof ConnectionError ? e diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index ee9d8e3982..376419f562 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -1,5 +1,6 @@ import { Mutex } from '@livekit/mutex'; import { SignalTarget } from '@livekit/protocol'; +import { ResultAsync, ok, okAsync } from 'neverthrow'; import log, { LoggerNames, getLogger } from '../logger'; import PCTransport, { PCEvents } from './PCTransport'; import { roomConnectOptionDefaults } from './defaults'; @@ -198,7 +199,10 @@ export class PCTransportManager { } } - async ensurePCTransportConnection(abortController?: AbortController, timeout?: number) { + async ensurePCTransportConnection( + abortController?: AbortController, + timeout?: number, + ): Promise> { const unlock = await this.connectionLock.lock(); try { if ( @@ -209,11 +213,11 @@ export class PCTransportManager { this.log.debug('negotiation required, start negotiating', this.logContext); this.publisher.negotiate(); } - await Promise.all( + return await ResultAsync.combine( this.requiredTransports?.map((transport) => this.ensureTransportConnected(transport, abortController, timeout), ), - ); + ).andThen(() => ok()); } finally { unlock(); } @@ -330,43 +334,46 @@ export class PCTransportManager { } }; - private async ensureTransportConnected( + private ensureTransportConnected( pcTransport: PCTransport, abortController?: AbortController, timeout: number = this.peerConnectionTimeout, - ) { + ): ResultAsync { const connectionState = pcTransport.getConnectionState(); if (connectionState === 'connected') { - return; + return okAsync(); } - return new Promise(async (resolve, reject) => { - const abortHandler = () => { - this.log.warn('abort transport connection', this.logContext); - CriticalTimers.clearTimeout(connectTimeout); - - reject(ConnectionError.cancelled('room connection has been cancelled')); - }; - if (abortController?.signal.aborted) { - abortHandler(); - } - abortController?.signal.addEventListener('abort', abortHandler); - - const connectTimeout = CriticalTimers.setTimeout(() => { - abortController?.signal.removeEventListener('abort', abortHandler); - reject(ConnectionError.internal('could not establish pc connection')); - }, timeout); + return ResultAsync.fromPromise( + new Promise(async (resolve, reject) => { + const abortHandler = () => { + this.log.warn('abort transport connection', this.logContext); + CriticalTimers.clearTimeout(connectTimeout); - while (this.state !== PCTransportState.CONNECTED) { - await sleep(50); // FIXME we shouldn't rely on `sleep` in the connection paths, as it invokes `setTimeout` which can be drastically throttled by browser implementations - if (abortController?.signal.aborted) { reject(ConnectionError.cancelled('room connection has been cancelled')); - return; + }; + if (abortController?.signal.aborted) { + abortHandler(); } - } - CriticalTimers.clearTimeout(connectTimeout); - abortController?.signal.removeEventListener('abort', abortHandler); - resolve(); - }); + abortController?.signal.addEventListener('abort', abortHandler); + + const connectTimeout = CriticalTimers.setTimeout(() => { + abortController?.signal.removeEventListener('abort', abortHandler); + reject(ConnectionError.internal('could not establish pc connection')); + }, timeout); + + while (this.state !== PCTransportState.CONNECTED) { + await sleep(50); // FIXME we shouldn't rely on `sleep` in the connection paths, as it invokes `setTimeout` which can be drastically throttled by browser implementations + if (abortController?.signal.aborted) { + reject(ConnectionError.cancelled('room connection has been cancelled')); + return; + } + } + CriticalTimers.clearTimeout(connectTimeout); + abortController?.signal.removeEventListener('abort', abortHandler); + resolve(); + }), + (e: unknown) => e as ConnectionError, + ); } } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index cb3ce668d1..8b95f8e6dc 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1119,7 +1119,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit self.client.setReconnected(); self.emit(EngineEvent.SignalRestarted, joinResult.value); - await self.waitForPCReconnected(); + yield* await self.waitForPCReconnected(); // re-check signal connection state before setting engine as resumed if (self.client.currentState !== SignalConnectionState.CONNECTED) { @@ -1150,7 +1150,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private async resumeConnection( reason?: ReconnectReason, - ): Promise> { + ): Promise> { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection return errAsync(new UnexpectedConnectionState('could not reconnect, url or token not saved')); @@ -1172,7 +1172,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ); if (reconnectResult.isErr()) { - return err(reconnectResult.error); + return err( + new SignalReconnectError( + `${reconnectResult.error.reasonName}: ${reconnectResult.error.message}`, + ), + ); } this.emit(EngineEvent.SignalResumed); @@ -1224,24 +1228,30 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!this.pcManager) { throw new UnexpectedConnectionState('PC manager is closed'); } - await this.pcManager.ensurePCTransportConnection(abortController, timeout); + return this.pcManager.ensurePCTransportConnection(abortController, timeout); } - private async waitForPCReconnected() { + private async waitForPCReconnected(): Promise< + Result + > { this.pcState = PCState.Reconnecting; this.log.debug('waiting for peer connection to reconnect', this.logContext); try { await sleep(minReconnectWait); // FIXME setTimeout again not ideal for a connection critical path if (!this.pcManager) { - throw new UnexpectedConnectionState('PC manager is closed'); + return err(new UnexpectedConnectionState('PC manager is closed')); } - await this.pcManager.ensurePCTransportConnection(undefined, this.peerConnectionTimeout); + const res = await this.pcManager.ensurePCTransportConnection( + undefined, + this.peerConnectionTimeout, + ); this.pcState = PCState.Connected; + return res; } catch (e: any) { // TODO do we need a `failed` state here for the PC? this.pcState = PCState.Disconnected; - throw ConnectionError.internal(`could not establish PC connection: ${e.message}`); + return err(ConnectionError.internal(`could not establish PC connection: ${e.message}`)); } } diff --git a/src/room/Room.ts b/src/room/Room.ts index 100b53bedb..1280d9b813 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -925,10 +925,15 @@ class Room extends (EventEmitter as new () => TypedEmitter) } try { - await this.engine.waitForPCInitialConnection( + const result = await this.engine.waitForPCInitialConnection( this.connOptions.peerConnectionTimeout, abortController, ); + if (result.isErr()) { + await this.engine.close(); + this.recreateEngine(); + throw result.error; + } } catch (e) { await this.engine.close(); this.recreateEngine(); From 6af2ac1fee6cf697619dfca1d1248a6db7987708 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 21 Nov 2025 17:41:56 +0100 Subject: [PATCH 58/58] differentiate errors better --- src/room/RTCEngine.ts | 20 ++++++++++++-------- src/room/errors.ts | 36 +++++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 8b95f8e6dc..9de406370e 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -39,7 +39,7 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; -import { type Result, err, errAsync, ok, safeTry } from 'neverthrow'; +import { type Result, err, errAsync, ok, okAsync, safeTry } from 'neverthrow'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; @@ -63,6 +63,7 @@ import { ConnectionError, ConnectionErrorReason, NegotiationError, + SignalReconnectError, SimulatedError, TrackInvalidError, UnexpectedConnectionState, @@ -1077,7 +1078,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private async restartConnection( regionUrl?: string, - ): Promise> { + ): Promise< + Result< + void, + UnexpectedConnectionState | SignalReconnectError | ConnectionError | SimulatedError + > + > { const self = this; const restartResultAsync = safeTry(async function* () { if (!self.url || !self.token) { @@ -1148,9 +1154,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return ok(restartResult.value); } - private async resumeConnection( + async resumeConnection( reason?: ReconnectReason, - ): Promise> { + ): Promise> { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection return errAsync(new UnexpectedConnectionState('could not reconnect, url or token not saved')); @@ -1172,7 +1178,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ); if (reconnectResult.isErr()) { - return err( + return errAsync( new SignalReconnectError( `${reconnectResult.error.reasonName}: ${reconnectResult.error.message}`, ), @@ -1221,7 +1227,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // resume success this.emit(EngineEvent.Resumed); - return ok(); + return okAsync(); } async waitForPCInitialConnection(timeout?: number, abortController?: AbortController) { @@ -1764,8 +1770,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } -class SignalReconnectError extends Error {} - export type EngineEventCallbacks = { connected: (joinResp: JoinResponse) => void; disconnected: (reason?: DisconnectReason) => void; diff --git a/src/room/errors.ts b/src/room/errors.ts index 455859bba5..efd52edf78 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -11,7 +11,7 @@ export class LivekitError extends Error { } export class SimulatedError extends LivekitError { - readonly type = 'simulated'; + readonly name = 'simulated'; constructor(message = 'Simulated failure') { super(-1, message); @@ -153,54 +153,69 @@ export class ConnectionError< } } +export class SignalReconnectError extends LivekitError { + readonly name = 'SignalReconnectError'; + + constructor(message?: string) { + super(12, message); + } +} + export class DeviceUnsupportedError extends LivekitError { + readonly name = 'DeviceUnsupportedError'; + constructor(message?: string) { super(21, message ?? 'device is unsupported'); - this.name = 'DeviceUnsupportedError'; } } export class TrackInvalidError extends LivekitError { + readonly name = 'TrackInvalidError'; + constructor(message?: string) { super(20, message ?? 'track is invalid'); - this.name = 'TrackInvalidError'; } } export class UnsupportedServer extends LivekitError { + readonly name = 'UnsupportedServer'; + constructor(message?: string) { super(10, message ?? 'unsupported server'); - this.name = 'UnsupportedServer'; } } export class UnexpectedConnectionState extends LivekitError { + readonly name = 'UnexpectedConnectionState'; + constructor(message?: string) { super(12, message ?? 'unexpected connection state'); - this.name = 'UnexpectedConnectionState'; } } export class NegotiationError extends LivekitError { + readonly name = 'NegotiationError'; + constructor(message?: string) { super(13, message ?? 'unable to negotiate'); - this.name = 'NegotiationError'; } } export class PublishDataError extends LivekitError { + readonly name = 'PublishDataError'; + constructor(message?: string) { super(14, message ?? 'unable to publish data'); - this.name = 'PublishDataError'; } } export class PublishTrackError extends LivekitError { + readonly name = 'PublishTrackError'; + status: number; constructor(message: string, status: number) { super(15, message); - this.name = 'PublishTrackError'; this.status = status; } } @@ -210,6 +225,8 @@ export type RequestErrorReason = | 'TimeoutError'; export class SignalRequestError extends LivekitError { + readonly name = 'SignalRequestError'; + reason: RequestErrorReason; reasonName: string; @@ -246,13 +263,14 @@ export enum DataStreamErrorReason { } export class DataStreamError extends LivekitError { + readonly name = 'DataStreamError'; + reason: DataStreamErrorReason; reasonName: string; constructor(message: string, reason: DataStreamErrorReason) { super(16, message); - this.name = 'DataStreamError'; this.reason = reason; this.reasonName = DataStreamErrorReason[reason]; }