From 65f0ca8bb09cb3835b9affbbc690e9c23f55dce9 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar Date: Mon, 17 Aug 2026 14:18:16 +0530 Subject: [PATCH] fix(calling): handle 409 error for keepalive --- docs/samples/calling/app.js | 10 + .../ai-docs/patterns/event-patterns.md | 8 +- .../calling/src/CallingClient/constants.ts | 3 + .../src/CallingClient/line/ai-docs/AGENTS.md | 4 +- .../line/ai-docs/ARCHITECTURE.md | 4 +- .../CallingClient/line/ai-docs/line-spec.md | 8 +- .../calling/src/CallingClient/line/index.ts | 1 + .../src/CallingClient/line/line.test.ts | 52 +++++ .../calling/src/CallingClient/line/types.ts | 1 + .../registration/ai-docs/AGENTS.md | 7 +- .../registration/ai-docs/ARCHITECTURE.md | 38 +++- .../registration/ai-docs/registration-spec.md | 48 ++++- .../registration/register.test.ts | 189 +++++++++++++++++- .../CallingClient/registration/register.ts | 100 ++++++++- .../src/CallingClient/registration/types.ts | 18 ++ packages/calling/src/Errors/types.ts | 2 + packages/calling/src/Events/types.ts | 6 + .../src/mobius-socket/ai-docs/AGENTS.md | 2 +- .../ai-docs/mobius-socket-spec.md | 2 +- 19 files changed, 477 insertions(+), 26 deletions(-) diff --git a/docs/samples/calling/app.js b/docs/samples/calling/app.js index 68a7a0e97e6..d18389cdb37 100644 --- a/docs/samples/calling/app.js +++ b/docs/samples/calling/app.js @@ -500,6 +500,16 @@ function createDevice() { console.log('Error: ', error); }); + // Mobius rejected the keepalive with 409: this session was superseded by another + // registration for the same user (for example calling opened in another tab). + // The SDK does not re-register after this event. + line.on('session_superseded', (error) => { + console.log('Session superseded: ', error.getError()); + registerElm.disabled = false; + unregisterElm.disabled = true; + registrationStatusElm.innerText = 'Session superseded by another tab or device'; + }); + // Start listening for incoming calls line.on('line:incoming_call', (callObj) => { call = callObj; diff --git a/packages/calling/ai-docs/patterns/event-patterns.md b/packages/calling/ai-docs/patterns/event-patterns.md index e8294ec5f30..31f13772ca0 100644 --- a/packages/calling/ai-docs/patterns/event-patterns.md +++ b/packages/calling/ai-docs/patterns/event-patterns.md @@ -114,6 +114,7 @@ export type LineEventTypes = { [LINE_EVENTS.RECONNECTING]: () => void; [LINE_EVENTS.REGISTERED]: (lineInfo: ILine) => void; [LINE_EVENTS.UNREGISTERED]: () => void; + [LINE_EVENTS.SESSION_SUPERSEDED]: (error: LineError) => void; [LINE_EVENTS.INCOMING_CALL]: (callObj: ICall) => void; }; ``` @@ -213,6 +214,7 @@ export enum LINE_EVENTS { RECONNECTING = 'reconnecting', REGISTERED = 'registered', UNREGISTERED = 'unregistered', + SESSION_SUPERSEDED = 'session_superseded', INCOMING_CALL = 'line:incoming_call', } @@ -305,6 +307,7 @@ public lineEmitter = (event: LINE_EVENTS, deviceInfo?: IDeviceInfo, lineError?: this.emit(event); // No payload break; case LINE_EVENTS.ERROR: + case LINE_EVENTS.SESSION_SUPERSEDED: if (lineError) { // Only emits if lineError is truthy this.emit(event, lineError); } @@ -332,12 +335,15 @@ this.lineEmitter(LINE_EVENTS.RECONNECTING); // On fatal registration failure — passes the error object this.lineEmitter(LINE_EVENTS.ERROR, undefined, clientError); + +// On a keepalive 409 Conflict — UNREGISTERED first (backward compatibility), then the terminal reason +this.lineEmitter(LINE_EVENTS.SESSION_SUPERSEDED, undefined, lineError); ``` Key behaviors: - **REGISTERED**: calls `normalizeLine(deviceInfo)` then emits `this` (the `ILine` instance), not `deviceInfo` -- **ERROR**: only emits if `lineError` is truthy +- **ERROR** / **SESSION_SUPERSEDED**: only emits if `lineError` is truthy - **UNREGISTERED / RECONNECTED / RECONNECTING**: emits with no args --- diff --git a/packages/calling/src/CallingClient/constants.ts b/packages/calling/src/CallingClient/constants.ts index 34780a49385..212c63472f8 100644 --- a/packages/calling/src/CallingClient/constants.ts +++ b/packages/calling/src/CallingClient/constants.ts @@ -133,6 +133,8 @@ export const ICE_CANDIDATES_TIMEOUT = 3000; // Reduced ICE candidates timeout used for ice-lite offers. export const ICE_LITE_CANDIDATES_TIMEOUT = 500; export const WCC_CALLING_RTMS_DOMAIN = 'wcc-calling-rtms-domain'; +export const SESSION_SUPERSEDED_MESSAGE = + 'This calling session has been superseded by another registration for the same user, for example calling opened in another browser tab or device. Calling is no longer available on this session.'; // Define constants for method names export const METHODS = { @@ -255,6 +257,7 @@ export const METHODS = { RESTART_REGISTRATION: 'restartRegistration', TRIGGER_REGISTRATION: 'triggerRegistration', HANDLE_404_KEEPALIVE_FAILURE: 'handle404KeepaliveFailure', + HANDLE_409_KEEPALIVE_FAILURE: 'handle409KeepaliveFailure', INITIATE_FAILBACK: 'initiateFailback', EXECUTE_FAILBACK: 'executeFailback', GET_RTMS_DOMAIN: 'getRTMSDomain', diff --git a/packages/calling/src/CallingClient/line/ai-docs/AGENTS.md b/packages/calling/src/CallingClient/line/ai-docs/AGENTS.md index 354a95a9587..a64baa7856f 100644 --- a/packages/calling/src/CallingClient/line/ai-docs/AGENTS.md +++ b/packages/calling/src/CallingClient/line/ai-docs/AGENTS.md @@ -97,7 +97,8 @@ constructor( |-------|------|---------|---------| | `connecting` | `LINE_EVENTS.CONNECTING` | _(none)_ | `register()` called | | `registered` | `LINE_EVENTS.REGISTERED` | `ILine` | Device registration succeeded | -| `unregistered` | `LINE_EVENTS.UNREGISTERED` | _(none)_ | Device deregistered | +| `unregistered` | `LINE_EVENTS.UNREGISTERED` | _(none)_ | Device deregistered, registration down, or session superseded | +| `session_superseded` | `LINE_EVENTS.SESSION_SUPERSEDED` | `LineError` | Mobius answered a keepalive with `409 Conflict` — this registration was superseded by another registration for the same user. No re-registration follows | | `reconnecting` | `LINE_EVENTS.RECONNECTING` | _(none)_ | Keepalive failure, attempting recovery | | `reconnected` | `LINE_EVENTS.RECONNECTED` | _(none)_ | Recovery succeeded | | `error` | `LINE_EVENTS.ERROR` | `LineError` | Registration or line error | @@ -207,6 +208,7 @@ export enum LINE_EVENTS { RECONNECTING = 'reconnecting', REGISTERED = 'registered', UNREGISTERED = 'unregistered', + SESSION_SUPERSEDED = 'session_superseded', INCOMING_CALL = 'line:incoming_call', } ``` diff --git a/packages/calling/src/CallingClient/line/ai-docs/ARCHITECTURE.md b/packages/calling/src/CallingClient/line/ai-docs/ARCHITECTURE.md index 429891e750c..a0a51f4ffff 100644 --- a/packages/calling/src/CallingClient/line/ai-docs/ARCHITECTURE.md +++ b/packages/calling/src/CallingClient/line/ai-docs/ARCHITECTURE.md @@ -78,8 +78,8 @@ flowchart TD B -->|RECONNECTED| H[emit RECONNECTED] B -->|RECONNECTING| I[emit RECONNECTING] - B -->|ERROR| J{lineError provided?} - J -- Yes --> K[emit ERROR with LineError] + B -->|ERROR or SESSION_SUPERSEDED| J{lineError provided?} + J -- Yes --> K[emit event with LineError] J -- No --> Z F --> APP[Application receives event] diff --git a/packages/calling/src/CallingClient/line/ai-docs/line-spec.md b/packages/calling/src/CallingClient/line/ai-docs/line-spec.md index 8345b43789f..c322c07ffa8 100644 --- a/packages/calling/src/CallingClient/line/ai-docs/line-spec.md +++ b/packages/calling/src/CallingClient/line/ai-docs/line-spec.md @@ -122,7 +122,8 @@ Compatibility notes: |-------|------|---------|---------| | `connecting` | `LINE_EVENTS.CONNECTING` | _(none)_ | `register()` called | | `registered` | `LINE_EVENTS.REGISTERED` | `ILine` | Device registration succeeded | -| `unregistered` | `LINE_EVENTS.UNREGISTERED` | _(none)_ | Device deregistered | +| `unregistered` | `LINE_EVENTS.UNREGISTERED` | _(none)_ | Device deregistered, registration down, or session superseded | +| `session_superseded` | `LINE_EVENTS.SESSION_SUPERSEDED` | `LineError` | Mobius answered a keepalive with `409 Conflict` — this registration was superseded by another registration for the same user. No re-registration follows | | `reconnecting` | `LINE_EVENTS.RECONNECTING` | _(none)_ | Keepalive failure, attempting recovery | | `reconnected` | `LINE_EVENTS.RECONNECTED` | _(none)_ | Recovery succeeded | | `error` | `LINE_EVENTS.ERROR` | `LineError` | Registration or line error | @@ -189,6 +190,7 @@ export enum LINE_EVENTS { RECONNECTING = 'reconnecting', REGISTERED = 'registered', UNREGISTERED = 'unregistered', + SESSION_SUPERSEDED = 'session_superseded', INCOMING_CALL = 'line:incoming_call', } ``` @@ -318,8 +320,8 @@ flowchart TD B -->|RECONNECTED| H[emit RECONNECTED] B -->|RECONNECTING| I[emit RECONNECTING] - B -->|ERROR| J{lineError provided?} - J -- Yes --> K[emit ERROR with LineError] + B -->|ERROR or SESSION_SUPERSEDED| J{lineError provided?} + J -- Yes --> K[emit event with LineError] J -- No --> Z F --> APP[Application receives event] diff --git a/packages/calling/src/CallingClient/line/index.ts b/packages/calling/src/CallingClient/line/index.ts index 0e9dfe18894..25157a598a3 100644 --- a/packages/calling/src/CallingClient/line/index.ts +++ b/packages/calling/src/CallingClient/line/index.ts @@ -205,6 +205,7 @@ export default class Line extends Eventing implements ILine { this.emit(event); break; case LINE_EVENTS.ERROR: + case LINE_EVENTS.SESSION_SUPERSEDED: if (lineError) { this.emit(event, lineError); } diff --git a/packages/calling/src/CallingClient/line/line.test.ts b/packages/calling/src/CallingClient/line/line.test.ts index 6dc8bfe1e45..0c65e8aa338 100644 --- a/packages/calling/src/CallingClient/line/line.test.ts +++ b/packages/calling/src/CallingClient/line/line.test.ts @@ -19,6 +19,8 @@ import {LINE_EVENTS} from './types'; import Line from '.'; import * as utils from '../../common/Utils'; import SDKConnector from '../../SDKConnector'; +import {createLineError} from '../../Errors/catalog/LineError'; +import {ERROR_TYPE} from '../../Errors/types'; import {REGISTRATION_FILE} from '../constants'; import {LOGGER} from '../../Logger/types'; import * as regUtils from '../registration/register'; @@ -227,6 +229,56 @@ describe('Line Tests', () => { }); }); + describe('Line event emission tests', () => { + let line; + + beforeEach(() => { + line = new Line( + userId, + clientDeviceUri, + mutex, + primaryMobiusUris(), + backupMobiusUris(), + LOGGER.INFO + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + line.removeAllListeners(); + }); + + it.each([LINE_EVENTS.ERROR, LINE_EVENTS.SESSION_SUPERSEDED])( + 're-emits %s with the line error to the consumer', + (event) => { + const listener = jest.fn(); + const lineError = createLineError( + 'session superseded', + {file: REGISTRATION_FILE, method: 'handle409KeepaliveFailure'}, + ERROR_TYPE.SESSION_SUPERSEDED, + RegistrationStatus.INACTIVE + ); + + line.on(event, listener); + line.lineEmitter(event, undefined, lineError); + + expect(listener).toBeCalledOnceWith(lineError); + } + ); + + it.each([LINE_EVENTS.ERROR, LINE_EVENTS.SESSION_SUPERSEDED])( + 'does not emit %s when no line error is provided', + (event) => { + const listener = jest.fn(); + + line.on(event, listener); + line.lineEmitter(event); + + expect(listener).not.toBeCalled(); + } + ); + }); + describe('Line calling tests', () => { let line; diff --git a/packages/calling/src/CallingClient/line/types.ts b/packages/calling/src/CallingClient/line/types.ts index 38450ad9b63..541333db653 100644 --- a/packages/calling/src/CallingClient/line/types.ts +++ b/packages/calling/src/CallingClient/line/types.ts @@ -18,6 +18,7 @@ export enum LINE_EVENTS { RECONNECTING = 'reconnecting', REGISTERED = 'registered', UNREGISTERED = 'unregistered', + SESSION_SUPERSEDED = 'session_superseded', INCOMING_CALL = 'line:incoming_call', } diff --git a/packages/calling/src/CallingClient/registration/ai-docs/AGENTS.md b/packages/calling/src/CallingClient/registration/ai-docs/AGENTS.md index 1a4585ad1d7..ba05b3cbf5f 100644 --- a/packages/calling/src/CallingClient/registration/ai-docs/AGENTS.md +++ b/packages/calling/src/CallingClient/registration/ai-docs/AGENTS.md @@ -89,6 +89,7 @@ A dedicated Web Worker manages keepalive requests to ensure a responsive and rel - Worker posts `KEEPALIVE_SUCCESS` **only when recovering** from a previous failure (`retryCount > 0` before the success). Normal successes silently reset the counter. - On **429**: `handle429Retry` clears the current worker and schedules a new keepalive timer after the `Retry-After` delay. +- On **409**: `handle409KeepaliveFailure` treats the failure as a hard stop on the very first occurrence — the retry count and the shared `handleRegistrationErrors` path are bypassed, the worker is terminated, no registration is attempted, and the consumer receives `UNREGISTERED` followed by `SESSION_SUPERSEDED`. - On **fatal error** (abort) or **retries exceeded** (retryCount >= threshold, 4 for CC / 5 otherwise): the worker is terminated and the main thread either calls `reconnectOnFailure` (non-fatal threshold) or attempts fresh registration (404). - On **non-fatal error below threshold**: only `LINE_EVENTS.RECONNECTING` is emitted; the worker keeps running. @@ -103,6 +104,7 @@ Robust error handling is built in for registration and keepalive via `handleRegi - **403 (Device Creation Disabled, code 102):** Fatal — `abort = true`. - **429 Too Many Requests:** Non-fatal — stores `Retry-After` value via `handle429Retry`. During initial registration, the loop continues to the next server; the stored value influences `startFailoverTimer` interval. During failback, retries up to `REG_FAILBACK_429_MAX_RETRIES` (5). - **500 / 503 / Other:** Non-fatal — the loop in `attemptRegistrationWithServers` continues to the next server. If all servers fail, `startFailoverTimer` schedules retries with exponential backoff. +- **409 Conflict (keepalive only):** Hard stop — handled by `handle409KeepaliveFailure` before `handleRegistrationErrors` runs, so no server loop, failover, or restore is attempted. Registration and failback paths still treat `409` as an unknown error. --- @@ -111,7 +113,7 @@ Robust error handling is built in for registration and keepalive via `handleRegi When Mobius emits a `REGISTRATION_DOWN` async event, `CallingClient` forwards it to `Registration.handleRegistrationDownEvent`: 1. Retrieves the first active call (if any) from `CallManager` and immediately calls `activeCall?.end()` to tear it down. -2. Calls `performRegistrationDownCleanup` unconditionally — there is no deferral, no `registrationDownPending` flag, and no polling interval. +2. Calls `performHardStopCleanup` unconditionally — there is no deferral, no `registrationDownPending` flag, and no polling interval. Cleanup (under the shared mutex) performs: - `clearFailbackTimer()` and `clearKeepaliveTimer()` @@ -119,9 +121,12 @@ Cleanup (under the shared mutex) performs: - `clearFailoverState()` and `setStatus(RegistrationStatus.INACTIVE)` - Disconnects the Mobius WebSocket when `apiRequest.isSocketEnabled()` (code `3050`, reason `'done (permanent)'`) - Emits `LINE_EVENTS.UNREGISTERED` via `lineEmitter` so the SDK consumer is notified +- For a superseded session only, additionally emits `LINE_EVENTS.SESSION_SUPERSEDED` with the `LineError` No `DELETE /devices/{id}` is sent because Mobius has already signaled that the registration is gone. +`performHardStopCleanup(caller, hardStop)` is shared with the keepalive `409 Conflict` path. The `HardStop` discriminated union (`src/CallingClient/registration/types.ts`) selects the log label (`registration-down` / `session-superseded`) and requires the `LineError` for a superseded session, so the terminal event and its payload cannot be mismatched. + --- ### 5. Metrics and Observability diff --git a/packages/calling/src/CallingClient/registration/ai-docs/ARCHITECTURE.md b/packages/calling/src/CallingClient/registration/ai-docs/ARCHITECTURE.md index c534ad7dea2..1c4fa2b7935 100644 --- a/packages/calling/src/CallingClient/registration/ai-docs/ARCHITECTURE.md +++ b/packages/calling/src/CallingClient/registration/ai-docs/ARCHITECTURE.md @@ -34,6 +34,7 @@ registration/ | Failover (primary → backup) | `startFailoverTimer()` with exponential backoff | | Failback (backup → primary) | `initiateFailback()` → `executeFailback()` | | 429 handling | `Retry-After` header with retry budget | +| 409 handling on keepalive | `handle409KeepaliveFailure()` → hard stop, no re-registration | | Reconnection | `handleConnectionRestoration()` / `reconnectOnFailure()` | | Deregistration | `DELETE /devices/{id}` + worker termination | | Mobius WSS connect/disconnect (when `apiRequest.isSocketEnabled()`) | Per-server `apiRequest.connectToMobiusSocket(wssNormalizedUrl)` inside `attemptRegistrationWithServers`; `apiRequest.disconnectFromMobiusSocket({code: 3050, reason: 'done (permanent)'})` on failover, failback, registration-down, restore-previous-registration, and deregister-with-`closeMobiusWss=true`. | @@ -488,7 +489,7 @@ When `apiRequest.isSocketEnabled()` is true (driven by `isMobiusWssEnabled(webex | `startFailoverTimer` switching primary → backup | disconnect primary WSS before backup re-registration | `register.ts ~ L508–L520` | | `executeFailback` primary recovered + no active calls | disconnect backup WSS before primary re-registration | `register.ts ~ L713–L725` | | `deregister(closeMobiusWss = true)` | disconnect WSS after DELETE returns | `register.ts ~ L1264–L1270` | -| `performRegistrationDownCleanup` (after Mobius async `registration.down`) | disconnect WSS as final cleanup step | `register.ts ~ L1411–L1419` | +| `performHardStopCleanup` (after Mobius async `registration.down`, or a keepalive `409 Conflict`) | disconnect WSS as final cleanup step | `register.ts` — `performHardStopCleanup` | ### Constants Used @@ -515,7 +516,7 @@ sequenceDiagram CC->>Reg: line.registration.handleRegistrationDownEvent(event) Reg->>CM: getActiveCalls() → end first active call - Reg->>Reg: performRegistrationDownCleanup() + Reg->>Reg: performHardStopCleanup(REGISTRATION_DOWN) Reg->>Reg: mutex.runExclusive(...) Reg->>Reg: clearFailbackTimer + clearKeepaliveTimer @@ -532,6 +533,39 @@ sequenceDiagram > **Note:** The synthetic `MOBIUS_SOCKET_4001_EVENT` envelope emitted when the server closes the socket with code `4001` carries `eventType: 'registration.down'` and therefore drives the **same** cleanup path as a server-pushed async `registration.down`. See [`mobius-socket/ai-docs/ARCHITECTURE.md`](../../../mobius-socket/ai-docs/ARCHITECTURE.md) for the close-code matrix. +### Keepalive `409 Conflict` — Session Superseded + +```mermaid +sequenceDiagram + participant WW as Keepalive Worker + participant Reg as Registration + participant MM as MetricManager + participant API as APIRequest + participant MS as MobiusSocket + participant Line as Line + + WW-->>Reg: KEEPALIVE_FAILURE {err.statusCode: 409, keepAliveRetryCount} + Note over Reg: statusCode === ERROR_CODE.CONFLICT
→ short-circuit before handleRegistrationErrors + Reg->>Reg: handle409KeepaliveFailure(err, serverType, retryCount) + Reg->>WW: clearKeepaliveTimer() → CLEAR_KEEPALIVE + terminate() + Reg->>Reg: createLineError(SESSION_SUPERSEDED_MESSAGE,
ERROR_TYPE.SESSION_SUPERSEDED, INACTIVE) + Reg->>MM: submitRegistrationMetric(KEEPALIVE_ERROR, KEEPALIVE_FAILURE, ...) + Reg->>Reg: performHardStopCleanup(SESSION_SUPERSEDED) + + opt apiRequest.isSocketEnabled() + Reg->>API: disconnectFromMobiusSocket({code:3050, reason:'done (permanent)'}) + API->>MS: disconnect + end + + Reg->>Line: lineEmitter(LINE_EVENTS.UNREGISTERED) + Reg->>Line: lineEmitter(LINE_EVENTS.SESSION_SUPERSEDED, undefined, lineError) + Reg->>Reg: uploadLogs() +``` + +The 409 short-circuit is scoped to the keepalive worker's `KEEPALIVE_FAILURE` branch. Registration, restoration, failover, and failback still route `409` through `handleRegistrationErrors`, and keepalive `404` / `429` / `5xx` handling is unchanged. + +--- + --- ## Related Documentation diff --git a/packages/calling/src/CallingClient/registration/ai-docs/registration-spec.md b/packages/calling/src/CallingClient/registration/ai-docs/registration-spec.md index 0d5d031092b..baf6299e1e0 100644 --- a/packages/calling/src/CallingClient/registration/ai-docs/registration-spec.md +++ b/packages/calling/src/CallingClient/registration/ai-docs/registration-spec.md @@ -97,6 +97,7 @@ registration/ | Failover (primary → backup) | `startFailoverTimer()` with exponential backoff | | Failback (backup → primary) | `initiateFailback()` → `executeFailback()` | | 429 handling | `Retry-After` header with retry budget | +| 409 handling on keepalive | `handle409KeepaliveFailure()` → hard stop, no re-registration | | Reconnection | `handleConnectionRestoration()` / `reconnectOnFailure()` | | Deregistration | `DELETE /devices/{id}` + worker termination | | Mobius WSS connect/disconnect (when `apiRequest.isSocketEnabled()`) | Per-server `apiRequest.connectToMobiusSocket(wssNormalizedUrl)` inside `attemptRegistrationWithServers`; `apiRequest.disconnectFromMobiusSocket({code: 3050, reason: 'done (permanent)'})` on failover, failback, registration-down, restore-previous-registration, and deregister-with-`closeMobiusWss=true`. | @@ -179,7 +180,7 @@ sequenceDiagram CC->>Reg: line.registration.handleRegistrationDownEvent(event) Reg->>CM: getActiveCalls() → end first active call - Reg->>Reg: performRegistrationDownCleanup() + Reg->>Reg: performHardStopCleanup(REGISTRATION_DOWN) Reg->>Reg: mutex.runExclusive(...) Reg->>Reg: clearFailbackTimer + clearKeepaliveTimer @@ -196,6 +197,39 @@ sequenceDiagram > **Note:** The synthetic `MOBIUS_SOCKET_4001_EVENT` envelope emitted when the server closes the socket with code `4001` carries `eventType: 'registration.down'` and therefore drives the **same** cleanup path as a server-pushed async `registration.down`. See [`mobius-socket/ai-docs/ARCHITECTURE.md`](../../../mobius-socket/ai-docs/ARCHITECTURE.md) for the close-code matrix. +### Keepalive `409 Conflict` — Session Superseded + +```mermaid +sequenceDiagram + participant WW as Keepalive Worker + participant Reg as Registration + participant MM as MetricManager + participant API as APIRequest + participant MS as MobiusSocket + participant Line as Line + + WW-->>Reg: KEEPALIVE_FAILURE {err.statusCode: 409, keepAliveRetryCount} + Note over Reg: statusCode === ERROR_CODE.CONFLICT
→ short-circuit before handleRegistrationErrors + Reg->>Reg: handle409KeepaliveFailure(err, serverType, retryCount) + Reg->>WW: clearKeepaliveTimer() → CLEAR_KEEPALIVE + terminate() + Reg->>Reg: createLineError(SESSION_SUPERSEDED_MESSAGE,
ERROR_TYPE.SESSION_SUPERSEDED, INACTIVE) + Reg->>MM: submitRegistrationMetric(KEEPALIVE_ERROR, KEEPALIVE_FAILURE, ...) + Reg->>Reg: performHardStopCleanup(SESSION_SUPERSEDED) + + opt apiRequest.isSocketEnabled() + Reg->>API: disconnectFromMobiusSocket({code:3050, reason:'done (permanent)'}) + API->>MS: disconnect + end + + Reg->>Line: lineEmitter(LINE_EVENTS.UNREGISTERED) + Reg->>Line: lineEmitter(LINE_EVENTS.SESSION_SUPERSEDED, undefined, lineError) + Reg->>Reg: uploadLogs() +``` + +The 409 short-circuit is scoped to the keepalive worker's `KEEPALIVE_FAILURE` branch. Registration, restoration, failover, and failback still route `409` through `handleRegistrationErrors`, and keepalive `404` / `429` / `5xx` handling is unchanged. + +--- + --- ## Requires (dependencies) @@ -217,6 +251,7 @@ sequenceDiagram | REGISTRATION-R-006 | HTTP `429` responses honor `Retry-After` according to context: registration/restoration may delay or switch servers, failback uses a capped retry count, and keepalive resumes after the adjusted delay. | Context-specific throttling prevents request storms without applying a failback or keepalive retry policy to an incompatible registration path. | `src/CallingClient/registration/register.ts` | `src/CallingClient/registration/register.test.ts` | none identified | PRESENT | | REGISTRATION-R-007 | `deregister(closeMobiusWss?)` deletes the active device, emits the unregistered lifecycle signal, stops keepalive, clears failover state, sets status to `INACTIVE`, and optionally closes Mobius WSS. | Complete cleanup prevents stale device registrations, timers, retry state, or transport sessions from surviving deregistration. | `src/CallingClient/registration/register.ts` | `src/CallingClient/registration/register.test.ts` | Direct tests do not isolate both values of `closeMobiusWss`; delete behavior is exercised through restoration/failback flows | PRESENT | | REGISTRATION-R-008 | Each server group selects HTTP or Mobius WSS from its URI scheme; WSS connects before registration and is disconnected with code `3050` / reason `done (permanent)` during lifecycle transitions that require a new session. | Aligning transport selection with the active server group prevents HTTP endpoints from being routed over WSS and avoids carrying an obsolete socket across failover, failback, or cleanup. | `src/CallingClient/registration/register.ts`; `src/CallingClient/utils/request.ts` | `src/CallingClient/registration/register.test.ts`; `src/CallingClient/utils/request.test.ts` | none identified | PRESENT | +| REGISTRATION-R-010 | A keepalive answered with `409 Conflict` is a hard stop on first occurrence: the keepalive worker is terminated, `handleRegistrationErrors` and every re-registration path are skipped, hard-stop cleanup closes WSS and sets status to `INACTIVE`, and the consumer receives `LINE_EVENTS.UNREGISTERED` followed by `LINE_EVENTS.SESSION_SUPERSEDED` carrying a `SESSION_SUPERSEDED` `LineError`; a `KEEPALIVE_ERROR` metric is submitted and logs are uploaded. | Mobius returns `409` when the same user registered elsewhere (typically a second browser tab), so re-registering would unregister that other device and start a registration ping-pong between the two. | `src/CallingClient/registration/register.ts` | `src/CallingClient/registration/register.test.ts` | none identified | PRESENT | | REGISTRATION-R-009 | A `registration.down` event ends the first active call, clears registration timers and transient retry state under the shared mutex, sets status to `INACTIVE`, optionally closes WSS, and emits `LINE_EVENTS.UNREGISTERED` without deleting a device already removed by Mobius. | Immediate, serialized cleanup prevents the SDK from retaining a registration or call that the server has declared invalid. | `src/CallingClient/registration/register.ts` | `src/CallingClient/registration/register.test.ts` | none identified | PRESENT | ### Key Capabilities @@ -231,6 +266,7 @@ The Registration module handles: - **429 Retry** — Respect `Retry-After` with context-specific registration, failback, and keepalive scheduling - **Deregistration** — `DELETE /devices/{deviceId}` to clean up the device on Mobius, with optional Mobius WebSocket teardown - **Registration-Down Cleanup** — End the first active call, clear registration-side timers/retry state, optionally close WSS, and emit `UNREGISTERED` without sending a redundant device DELETE +- **Session-Superseded Hard Stop** — A keepalive answered with `409 Conflict` stops the keepalive worker, skips re-registration, closes WSS, and emits `UNREGISTERED` followed by `SESSION_SUPERSEDED` - **Mobius WSS Lifecycle (when `apiRequest.isSocketEnabled()`)** — Connect to the per-server WSS URL before `POST /device`, and disconnect with `{code: 3050, reason: 'done (permanent)'}` on failover, failback, registration-down cleanup, restore-previous-registration, and `deregister(closeMobiusWss = true)`. See [`mobius-socket/ai-docs/AGENTS.md`](../../../mobius-socket/ai-docs/AGENTS.md) for the close-code policy. ## Design Overview @@ -256,7 +292,7 @@ This section provides an overview of the core concepts and flows managed by the When Mobius emits a `REGISTRATION_DOWN` async event, `CallingClient` forwards it to `Registration.handleRegistrationDownEvent`: 1. Retrieves the first active call (if any) from `CallManager` and immediately calls `activeCall?.end()` to tear it down. -2. Calls `performRegistrationDownCleanup` unconditionally — there is no deferral, no `registrationDownPending` flag, and no polling interval. +2. Calls `performHardStopCleanup` unconditionally — there is no deferral, no `registrationDownPending` flag, and no polling interval. Cleanup (under the shared mutex) performs: - `clearFailbackTimer()` and `clearKeepaliveTimer()` @@ -264,9 +300,12 @@ Cleanup (under the shared mutex) performs: - `clearFailoverState()` and `setStatus(RegistrationStatus.INACTIVE)` - Disconnects the Mobius WebSocket when `apiRequest.isSocketEnabled()` (code `3050`, reason `'done (permanent)'`) - Emits `LINE_EVENTS.UNREGISTERED` via `lineEmitter` so the SDK consumer is notified +- For a superseded session only, additionally emits `LINE_EVENTS.SESSION_SUPERSEDED` with the `LineError` No `DELETE /devices/{id}` is sent because Mobius has already signaled that the registration is gone. +`performHardStopCleanup(caller, hardStop)` is shared with the keepalive `409 Conflict` path. The `HardStop` discriminated union (`src/CallingClient/registration/types.ts`) selects the log label (`registration-down` / `session-superseded`) and requires the `LineError` for a superseded session, so the terminal event and its payload cannot be mismatched. + ### 5. Metrics and Observability Registration events are instrumented with detailed metrics for observability and troubleshooting: @@ -375,7 +414,7 @@ When `apiRequest.isSocketEnabled()` is true (driven by `isMobiusWssEnabled(webex | `startFailoverTimer` switching primary → backup | disconnect primary WSS before backup re-registration | `register.ts ~ L508–L520` | | `executeFailback` primary recovered + no active calls | disconnect backup WSS before primary re-registration | `register.ts ~ L713–L725` | | `deregister(closeMobiusWss = true)` | disconnect WSS after DELETE returns | `register.ts ~ L1264–L1270` | -| `performRegistrationDownCleanup` (after Mobius async `registration.down`) | disconnect WSS as final cleanup step | `register.ts ~ L1411–L1419` | +| `performHardStopCleanup` (after Mobius async `registration.down`, or a keepalive `409 Conflict`) | disconnect WSS as final cleanup step | `register.ts` — `performHardStopCleanup` | ### Constants Used @@ -403,6 +442,7 @@ A dedicated Web Worker manages keepalive requests to ensure a responsive and rel - Worker posts `KEEPALIVE_SUCCESS` **only when recovering** from a previous failure (`retryCount > 0` before the success). Normal successes silently reset the counter. - On **429**: `handle429Retry` clears the current worker and schedules a new keepalive timer after the `Retry-After` delay. +- On **409**: `handle409KeepaliveFailure` treats the failure as a hard stop on the very first occurrence — the retry count and the shared `handleRegistrationErrors` path are bypassed, the worker is terminated, no registration is attempted, and the consumer receives `UNREGISTERED` followed by `SESSION_SUPERSEDED`. - On **fatal error** (abort) or **retries exceeded** (retryCount >= threshold, 4 for CC / 5 otherwise): the worker is terminated and the main thread either calls `reconnectOnFailure` (non-fatal threshold) or attempts fresh registration (404). - On **non-fatal error below threshold**: only `LINE_EVENTS.RECONNECTING` is emitted; the worker keeps running. @@ -749,6 +789,7 @@ Robust error handling is built in for registration and keepalive via `handleRegi - **403 (Device Creation Disabled, code 102):** Fatal — `abort = true`. - **429 Too Many Requests:** Non-fatal — stores `Retry-After` value via `handle429Retry`. During initial registration, the loop continues to the next server; the stored value influences `startFailoverTimer` interval. During failback, retries up to `REG_FAILBACK_429_MAX_RETRIES` (5). - **500 / 503 / Other:** Non-fatal — the loop in `attemptRegistrationWithServers` continues to the next server. If all servers fail, `startFailoverTimer` schedules retries with exponential backoff. +- **409 Conflict (keepalive only):** Hard stop — handled by `handle409KeepaliveFailure` before `handleRegistrationErrors` runs, so no server loop, failover, or restore is attempted. Registration and failback paths still treat `409` as an unknown error. ### 429 Retry Logic @@ -943,6 +984,7 @@ Unit tests are co-located under `src/CallingClient/registration/` and exercise p | REGISTRATION-R-007 | `src/CallingClient/registration/register.test.ts` | Add isolated coverage for `deregister(false)` and `deregister(true)`, including WSS teardown failure | | REGISTRATION-R-008 | `src/CallingClient/registration/register.test.ts`; `src/CallingClient/utils/request.test.ts` | Re-check negative/error edge coverage during independent validation | | REGISTRATION-R-009 | `src/CallingClient/registration/register.test.ts` | Re-check negative/error edge coverage during independent validation | +| REGISTRATION-R-010 | `src/CallingClient/registration/register.test.ts` | Re-check negative/error edge coverage during independent validation | ## Traceability diff --git a/packages/calling/src/CallingClient/registration/register.test.ts b/packages/calling/src/CallingClient/registration/register.test.ts index 40c08c5741b..e030d716e7e 100644 --- a/packages/calling/src/CallingClient/registration/register.test.ts +++ b/packages/calling/src/CallingClient/registration/register.test.ts @@ -34,10 +34,11 @@ import { SEC_TO_MSEC_MFACTOR, RECONNECT_ON_FAILURE_UTIL, METHODS, + SESSION_SUPERSEDED_MESSAGE, } from '../constants'; import {ICall} from '../calling/types'; import {LINE_EVENTS} from '../line/types'; -import {createLineError} from '../../Errors/catalog/LineError'; +import {createLineError, LineError} from '../../Errors/catalog/LineError'; import {IRegistration} from './types'; import {METRIC_EVENT, REG_ACTION, METRIC_TYPE} from '../../Metrics/types'; import {APIRequest} from '../utils/request'; @@ -1853,6 +1854,187 @@ describe('Registration Tests', () => { expect(retry429Spy).toBeCalledOnceWith(20, RECONNECT_ON_FAILURE_UTIL); expect(reg.retryAfter).toEqual(undefined); // Clear retryAfter after 429 retry }); + + describe('409 Conflict on keepalive (session superseded)', () => { + const conflictError = {statusCode: 409, headers: {trackingid: 'tid-409'}}; + + const sendKeepaliveFailure = async (err, keepAliveRetryCount) => { + reg.webWorker.onmessage({ + data: { + type: WorkerMessageType.KEEPALIVE_FAILURE, + err, + keepAliveRetryCount, + }, + } as MessageEvent); + + await flushPromises(); + }; + + const getSupersededError = (): LineError => { + const supersededCall = lineEmitter.mock.calls.find( + ([event]) => event === LINE_EVENTS.SESSION_SUPERSEDED + ); + + return supersededCall?.[2]; + }; + + it.each([ + {description: 'on the very first keepalive failure', keepAliveRetryCount: 1}, + {description: 'when the retry count already reached the threshold', keepAliveRetryCount: 5}, + ])('stops keepalive without re-registration $description', async ({keepAliveRetryCount}) => { + await beforeEachSetupForKeepalive(); + const clearTimerSpy = jest.spyOn(reg, 'clearKeepaliveTimer'); + const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure'); + const registerSpy = jest.spyOn(reg, 'attemptRegistrationWithServers'); + const handle404Spy = jest.spyOn(reg, 'handle404KeepaliveFailure'); + lineEmitter.mockClear(); + + await sendKeepaliveFailure(conflictError, keepAliveRetryCount); + + expect(warnSpy).toBeCalledWith( + `Keepalive received 409 Conflict, registration superseded by another device for this user. Stopping keepalive without re-registration - keepaliveRetryCount: ${keepAliveRetryCount}`, + {file: REGISTRATION_FILE, method: METHODS.HANDLE_409_KEEPALIVE_FAILURE} + ); + + // Keepalive is stopped and no further keepalive can be sent. + expect(clearTimerSpy).toHaveBeenCalled(); + expect(reg.webWorker).toBeUndefined(); + expect(reg.getStatus()).toEqual(RegistrationStatus.INACTIVE); + expect(reg.failbackTimer).toStrictEqual(undefined); + + // No re-registration of any kind is attempted. + expect(handleErrorSpy).not.toHaveBeenCalled(); + expect(reconnectSpy).not.toHaveBeenCalled(); + expect(restoreSpy).not.toHaveBeenCalled(); + expect(restartSpy).not.toHaveBeenCalled(); + expect(failoverSpy).not.toHaveBeenCalled(); + expect(registerSpy).not.toHaveBeenCalled(); + expect(handle404Spy).not.toHaveBeenCalled(); + expect(reg.reconnectPending).toStrictEqual(false); + }); + + it('notifies the consumer with UNREGISTERED followed by SESSION_SUPERSEDED', async () => { + await beforeEachSetupForKeepalive(); + lineEmitter.mockClear(); + + await sendKeepaliveFailure(conflictError, 1); + + expect( + lineEmitter.mock.calls + .map(([event]) => event) + .filter((event) => + [LINE_EVENTS.UNREGISTERED, LINE_EVENTS.SESSION_SUPERSEDED].includes(event) + ) + ).toStrictEqual([LINE_EVENTS.UNREGISTERED, LINE_EVENTS.SESSION_SUPERSEDED]); + expect(lineEmitter).not.toHaveBeenCalledWith(LINE_EVENTS.RECONNECTING); + expect(lineEmitter).toHaveBeenLastCalledWith( + LINE_EVENTS.SESSION_SUPERSEDED, + undefined, + expect.any(LineError) + ); + + const supersededError = getSupersededError(); + + expect(supersededError.getError()).toStrictEqual({ + message: SESSION_SUPERSEDED_MESSAGE, + type: ERROR_TYPE.SESSION_SUPERSEDED, + status: RegistrationStatus.INACTIVE, + context: {file: REGISTRATION_FILE, method: METHODS.HANDLE_409_KEEPALIVE_FAILURE}, + }); + }); + + it('submits the keepalive failure metric and uploads logs', async () => { + await beforeEachSetupForKeepalive(); + const uploadLogsSpy = jest.spyOn(utils, 'uploadLogs'); + lineEmitter.mockClear(); + metricSpy.mockClear(); + + await sendKeepaliveFailure(conflictError, 2); + + expect(metricSpy).toBeCalledOnceWith( + METRIC_EVENT.KEEPALIVE_ERROR, + REG_ACTION.KEEPALIVE_FAILURE, + METRIC_TYPE.BEHAVIORAL, + METHODS.HANDLE_409_KEEPALIVE_FAILURE, + 'PRIMARY', + conflictError.headers.trackingid, + 2, + getSupersededError() + ); + expect(uploadLogsSpy).toHaveBeenCalled(); + }); + + it('closes the Mobius WebSocket when the socket transport is enabled', async () => { + await beforeEachSetupForKeepalive(); + const apiRequest = APIRequest.getInstance({webex}); + jest.spyOn(apiRequest, 'isSocketEnabled').mockReturnValue(true); + const disconnectSocketSpy = jest + .spyOn(apiRequest, 'disconnectFromMobiusSocket') + .mockResolvedValue(); + lineEmitter.mockClear(); + + await sendKeepaliveFailure(conflictError, 1); + + expect(disconnectSocketSpy).toBeCalledOnceWith({ + code: 3050, + reason: 'done (permanent)', + }); + expect(lineEmitter).toHaveBeenLastCalledWith( + LINE_EVENTS.SESSION_SUPERSEDED, + undefined, + expect.any(LineError) + ); + }); + + it('still notifies the consumer when the Mobius WebSocket teardown fails', async () => { + await beforeEachSetupForKeepalive(); + const apiRequest = APIRequest.getInstance({webex}); + jest.spyOn(apiRequest, 'isSocketEnabled').mockReturnValue(true); + jest + .spyOn(apiRequest, 'disconnectFromMobiusSocket') + .mockRejectedValue(new Error('socket teardown failed')); + lineEmitter.mockClear(); + + await sendKeepaliveFailure(conflictError, 1); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Mobius socket disconnect failed after session-superseded'), + {file: REGISTRATION_FILE, method: METHODS.HANDLE_409_KEEPALIVE_FAILURE} + ); + expect(reg.getStatus()).toEqual(RegistrationStatus.INACTIVE); + expect(lineEmitter).toHaveBeenLastCalledWith( + LINE_EVENTS.SESSION_SUPERSEDED, + undefined, + expect.any(LineError) + ); + }); + + it.each([ + {description: '404 device not found', err: {statusCode: 404}}, + {description: '429 too many requests', err: {statusCode: 429, headers: {'retry-after': 5}}}, + {description: '503 service unavailable', err: {statusCode: 503}}, + ])('leaves the existing handling of $description untouched', async ({err}) => { + await beforeEachSetupForKeepalive(); + const handle409Spy = jest.spyOn(reg, 'handle409KeepaliveFailure'); + lineEmitter.mockClear(); + handleErrorSpy.mockClear(); + + await sendKeepaliveFailure(err, 1); + + expect(handle409Spy).not.toHaveBeenCalled(); + expect(handleErrorSpy).toHaveBeenCalledWith( + err, + expect.anything(), + {file: REGISTRATION_FILE, method: KEEPALIVE_UTIL}, + expect.anything() + ); + expect(lineEmitter).not.toHaveBeenCalledWith( + LINE_EVENTS.SESSION_SUPERSEDED, + undefined, + expect.anything() + ); + }); + }); }); describe('Primary server status checks', () => { @@ -1939,6 +2121,11 @@ describe('Registration Tests', () => { expect(reg.registerRetry).toBe(false); expect(disconnectSocketSpy).not.toHaveBeenCalled(); expect(lineEmitter).toHaveBeenCalledWith(LINE_EVENTS.UNREGISTERED); + expect(lineEmitter).not.toHaveBeenCalledWith( + LINE_EVENTS.SESSION_SUPERSEDED, + undefined, + expect.anything() + ); }); it('ends the active call and still runs cleanup when an active call is present', async () => { diff --git a/packages/calling/src/CallingClient/registration/register.ts b/packages/calling/src/CallingClient/registration/register.ts index 0073ee18ec4..ef4d8f3af08 100644 --- a/packages/calling/src/CallingClient/registration/register.ts +++ b/packages/calling/src/CallingClient/registration/register.ts @@ -15,7 +15,7 @@ import {ICallManager, MobiusAsyncEvent} from '../calling/types'; import {getCallManager} from '../calling'; import {LOGGER} from '../../Logger/types'; import log from '../../Logger'; -import {FailoverCacheState, IRegistration} from './types'; +import {FailoverCacheState, HardStop, HARD_STOP_REASON, IRegistration} from './types'; import SDKConnector from '../../SDKConnector'; import { ALLOWED_SERVICES, @@ -56,9 +56,11 @@ import { URL_ENDPOINT, RECONNECT_ON_FAILURE_UTIL, FAILOVER_CACHE_PREFIX, + SESSION_SUPERSEDED_MESSAGE, } from '../constants'; import {LINE_EVENTS, LineEmitterCallback} from '../line/types'; -import {LineError} from '../../Errors/catalog/LineError'; +import {createLineError, LineError} from '../../Errors/catalog/LineError'; +import {ERROR_CODE, ERROR_TYPE} from '../../Errors/types'; import {APIRequest} from '../utils/request'; /** @@ -386,6 +388,64 @@ export class Registration implements IRegistration { } } + /** + * Handles a 409 Conflict response to a keepalive (device_status) message. + * + * Mobius returns 409 when this deviceId is gone but the same user still has an active + * registration elsewhere (typically calling opened in another browser tab, which + * superseded this device). Re-registering here would unregister the other device and + * restart the registration ping-pong between the two, so this is a hard stop: the + * keepalive worker is terminated, no registration is attempted, the Mobius WebSocket is + * closed and the consumer is notified through `LINE_EVENTS.SESSION_SUPERSEDED`. + * + * @param error - The keepalive failure payload forwarded by the keepalive worker. + * @param serverType - Mobius server type the keepalive was sent to, for metrics. + * @param keepaliveRetryCount - Consecutive keepalive failures reported by the worker. + */ + private async handle409KeepaliveFailure( + error: WebexRequestPayload, + serverType: SERVER_TYPE, + keepaliveRetryCount: number + ): Promise { + const loggerContext = { + file: REGISTRATION_FILE, + method: METHODS.HANDLE_409_KEEPALIVE_FAILURE, + }; + + log.warn( + `Keepalive received 409 Conflict, registration superseded by another device for this user. Stopping keepalive without re-registration - keepaliveRetryCount: ${keepaliveRetryCount}`, + loggerContext + ); + + /* Stop the keepalive worker up front so no further keepalive is sent while cleanup waits on the mutex. */ + this.clearKeepaliveTimer(); + + const lineError = createLineError( + SESSION_SUPERSEDED_MESSAGE, + loggerContext, + ERROR_TYPE.SESSION_SUPERSEDED, + RegistrationStatus.INACTIVE + ); + + this.metricManager.submitRegistrationMetric( + METRIC_EVENT.KEEPALIVE_ERROR, + REG_ACTION.KEEPALIVE_FAILURE, + METRIC_TYPE.BEHAVIORAL, + METHODS.HANDLE_409_KEEPALIVE_FAILURE, + serverType, + error.headers?.trackingid ?? '', + keepaliveRetryCount, + lineError + ); + + await this.performHardStopCleanup(METHODS.HANDLE_409_KEEPALIVE_FAILURE, { + reason: HARD_STOP_REASON.SESSION_SUPERSEDED, + error: lineError, + }); + + await uploadLogs(); + } + /** * Callback for handling 429 retry response from the server */ @@ -1193,6 +1253,16 @@ export class Registration implements IRegistration { logContext ); + if (Number(error.statusCode) === ERROR_CODE.CONFLICT) { + await this.handle409KeepaliveFailure( + error, + serverType, + event.data.keepAliveRetryCount + ); + + return; + } + const {finalError: abort} = await handleRegistrationErrors( error, (clientError, finalError) => { @@ -1387,27 +1457,33 @@ export class Registration implements IRegistration { const [activeCall] = Object.values(this.callManager.getActiveCalls()); activeCall?.end(); - await this.performRegistrationDownCleanup(METHODS.HANDLE_REGISTRATION_DOWN_EVENT); + await this.performHardStopCleanup(METHODS.HANDLE_REGISTRATION_DOWN_EVENT, { + reason: HARD_STOP_REASON.REGISTRATION_DOWN, + }); } /** - * Cleans up registration-side state after a Mobius registration-down event. + * Cleans up registration-side state for a hard stop, i.e. a registration that is gone + * and must not be re-established by the SDK (Mobius registration-down event, or a + * session superseded by another registration for the same user). * * Stops timers, resets transient flags, clears failover cache, sets status to * INACTIVE, tears down the Mobius WebSocket (when enabled), and finally emits - * `LINE_EVENTS.UNREGISTERED` so the SDK consumer is notified. + * `LINE_EVENTS.UNREGISTERED` so the SDK consumer is notified. A superseded session + * additionally emits `LINE_EVENTS.SESSION_SUPERSEDED` with the reason. * * Runs under the shared mutex to avoid racing with other registration flows. * * @param caller - Identifier of the caller, used for logs. + * @param hardStop - Why the registration is being torn down as {@link HardStop}. */ - private async performRegistrationDownCleanup(caller: string): Promise { + private async performHardStopCleanup(caller: string, hardStop: HardStop): Promise { const loggerContext = { file: REGISTRATION_FILE, - method: METHODS.HANDLE_REGISTRATION_DOWN_EVENT, + method: caller, }; - log.info(`[${caller}] : Running registration-down cleanup`, loggerContext); + log.info(`[${caller}] : Running ${hardStop.reason} cleanup`, loggerContext); await this.mutex.runExclusive(async () => { this.clearFailbackTimer(); @@ -1428,16 +1504,20 @@ export class Registration implements IRegistration { code: 3050, reason: 'done (permanent)', }); - log.log('Mobius socket disconnect complete after registration-down', loggerContext); + log.log(`Mobius socket disconnect complete after ${hardStop.reason}`, loggerContext); } catch (err) { log.warn( - `Mobius socket disconnect failed after registration-down: ${String(err)}`, + `Mobius socket disconnect failed after ${hardStop.reason}: ${String(err)}`, loggerContext ); } } this.lineEmitter(LINE_EVENTS.UNREGISTERED); + + if (hardStop.reason === HARD_STOP_REASON.SESSION_SUPERSEDED) { + this.lineEmitter(LINE_EVENTS.SESSION_SUPERSEDED, undefined, hardStop.error); + } }); } } diff --git a/packages/calling/src/CallingClient/registration/types.ts b/packages/calling/src/CallingClient/registration/types.ts index 9c761870093..63111fbb352 100644 --- a/packages/calling/src/CallingClient/registration/types.ts +++ b/packages/calling/src/CallingClient/registration/types.ts @@ -1,10 +1,28 @@ import {Devices, IDeviceInfo, RegistrationStatus} from '../../common/types'; +import {LineError} from '../../Errors/catalog/LineError'; import {MobiusAsyncEvent} from '../calling/types'; export type Header = { [key: string]: string; }; +/** + * Reason a registration is torn down without any re-registration attempt. + * The values double as the label used in hard-stop log messages. + */ +export enum HARD_STOP_REASON { + REGISTRATION_DOWN = 'registration-down', + SESSION_SUPERSEDED = 'session-superseded', +} + +/** + * Describes a hard stop of the registration. A superseded session must carry the + * {@link LineError} that is handed to the SDK consumer with the terminal event. + */ +export type HardStop = + | {reason: HARD_STOP_REASON.REGISTRATION_DOWN} + | {reason: HARD_STOP_REASON.SESSION_SUPERSEDED; error: LineError}; + export type restoreRegistrationCallBack = ( restoreData: IDeviceInfo, caller: string diff --git a/packages/calling/src/Errors/types.ts b/packages/calling/src/Errors/types.ts index 79678dfb58b..7d13a7fc50a 100644 --- a/packages/calling/src/Errors/types.ts +++ b/packages/calling/src/Errors/types.ts @@ -16,6 +16,7 @@ export enum ERROR_TYPE { NOT_FOUND = 'not_found', REGISTRATION_ERROR = 'registration_error', SERVICE_UNAVAILABLE = 'service_unavailable', + SESSION_SUPERSEDED = 'session_superseded', TIMEOUT = 'timeout', TOKEN_ERROR = 'token_error', TOO_MANY_REQUESTS = 'too_many_requests', @@ -26,6 +27,7 @@ export enum ERROR_CODE { UNAUTHORIZED = 401, FORBIDDEN = 403, DEVICE_NOT_FOUND = 404, + CONFLICT = 409, INTERNAL_SERVER_ERROR = 500, NOT_IMPLEMENTED = 501, SERVICE_UNAVAILABLE = 503, diff --git a/packages/calling/src/Events/types.ts b/packages/calling/src/Events/types.ts index cc00e17fc40..c4feb82c1b6 100644 --- a/packages/calling/src/Events/types.ts +++ b/packages/calling/src/Events/types.ts @@ -213,6 +213,12 @@ export type LineEventTypes = { [LINE_EVENTS.RECONNECTING]: () => void; [LINE_EVENTS.REGISTERED]: (lineInfo: ILine) => void; [LINE_EVENTS.UNREGISTERED]: () => void; + /** + * Emitted when Mobius reports that this registration was superseded by another + * registration for the same user (for example, calling opened in a second browser tab). + * The line is not re-registered after this event. + */ + [LINE_EVENTS.SESSION_SUPERSEDED]: (error: LineError) => void; [LINE_EVENTS.INCOMING_CALL]: (callObj: ICall) => void; }; diff --git a/packages/calling/src/mobius-socket/ai-docs/AGENTS.md b/packages/calling/src/mobius-socket/ai-docs/AGENTS.md index 366c8c95f8e..2d87917f2d7 100644 --- a/packages/calling/src/mobius-socket/ai-docs/AGENTS.md +++ b/packages/calling/src/mobius-socket/ai-docs/AGENTS.md @@ -150,7 +150,7 @@ interface MobiusSocketConfig { |---|---|---| | `isSocketEnabled()` | `isMobiusWssEnabled(webex)` (in `utils/wsFeatureFlag.ts`) | `CallingClient`, `Registration`, `CallManager` (to skip the Mercury `event:mobius` listener when WSS is on). | | `connectToMobiusSocket(wssUrl)` | `MobiusSocket#isConnected`, `MobiusSocket#connect`, `MobiusSocket#getConnectedWebSocketUrl` | `CallingClient.connectToMobiusSocket` (post-discovery), `Registration.attemptRegistrationWithServers` (per server URI). | -| `disconnectFromMobiusSocket(options?)` | `MobiusSocket#disconnect` | `Registration.restorePreviousRegistration` / `startFailoverTimer` / `executeFailback` / `deregister` / `performRegistrationDownCleanup` / `attemptRegistrationWithServers` error branch. | +| `disconnectFromMobiusSocket(options?)` | `MobiusSocket#disconnect` | `Registration.restorePreviousRegistration` / `startFailoverTimer` / `executeFailback` / `deregister` / `performHardStopCleanup` / `attemptRegistrationWithServers` error branch. | | `getConnectedWebSocketUrl()` | `MobiusSocket#getConnectedWebSocketUrl` | `Registration` (to decide whether the current connection still matches `activeMobiusUrl`). | | `makeRequest(request)` | `MobiusSocket#sendWssRequest` when WSS is enabled, otherwise `webex.request` | Mobius REST traffic routed through `APIRequest`: register, deregister, call setup/state/media/status, supplementary services, keepalive 404 recovery, etc. **Not** used for Mobius server discovery (`getMobiusServers`), device listing (`getDevices`), or failback health pings (`Registration.isPrimaryActive`) — those call `webex.request()` directly. | | `registerMobiusSocketListener(cb)` | `MobiusSocket#on('event:async_event', cb)` | `CallingClient.init` (when WSS is enabled), invoking `handleMobiusAsyncEvent` to fan out to `CallManager.dequeueWsEvents` or `Registration.handleRegistrationDownEvent`. | diff --git a/packages/calling/src/mobius-socket/ai-docs/mobius-socket-spec.md b/packages/calling/src/mobius-socket/ai-docs/mobius-socket-spec.md index 6bc24457020..5abc5494325 100644 --- a/packages/calling/src/mobius-socket/ai-docs/mobius-socket-spec.md +++ b/packages/calling/src/mobius-socket/ai-docs/mobius-socket-spec.md @@ -338,7 +338,7 @@ flowchart LR |---|---|---| | `isSocketEnabled()` | `isMobiusWssEnabled(webex)` (in `utils/wsFeatureFlag.ts`) | `CallingClient`, `Registration`, `CallManager` (to skip the Mercury `event:mobius` listener when WSS is on). | | `connectToMobiusSocket(wssUrl)` | `MobiusSocket#isConnected`, `MobiusSocket#connect`, `MobiusSocket#getConnectedWebSocketUrl` | `CallingClient.connectToMobiusSocket` (post-discovery), `Registration.attemptRegistrationWithServers` (per server URI). | -| `disconnectFromMobiusSocket(options?)` | `MobiusSocket#disconnect` | `Registration.restorePreviousRegistration` / `startFailoverTimer` / `executeFailback` / `deregister` / `performRegistrationDownCleanup` / `attemptRegistrationWithServers` error branch. | +| `disconnectFromMobiusSocket(options?)` | `MobiusSocket#disconnect` | `Registration.restorePreviousRegistration` / `startFailoverTimer` / `executeFailback` / `deregister` / `performHardStopCleanup` / `attemptRegistrationWithServers` error branch. | | `getConnectedWebSocketUrl()` | `MobiusSocket#getConnectedWebSocketUrl` | `Registration` (to decide whether the current connection still matches `activeMobiusUrl`). | | `makeRequest(request)` | `MobiusSocket#sendWssRequest` when WSS is enabled, otherwise `webex.request` | Mobius REST traffic routed through `APIRequest`: register, deregister, call setup/state/media/status, supplementary services, keepalive 404 recovery, etc. **Not** used for Mobius server discovery (`getMobiusServers`), device listing (`getDevices`), or failback health pings (`Registration.isPrimaryActive`) — those call `webex.request()` directly. | | `registerMobiusSocketListener(cb)` | `MobiusSocket#on('event:async_event', cb)` | `CallingClient.init` (when WSS is enabled), invoking `handleMobiusAsyncEvent` to fan out to `CallManager.dequeueWsEvents` or `Registration.handleRegistrationDownEvent`. |