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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/samples/calling/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion packages/calling/ai-docs/patterns/event-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
```
Expand Down Expand Up @@ -213,6 +214,7 @@ export enum LINE_EVENTS {
RECONNECTING = 'reconnecting',
REGISTERED = 'registered',
UNREGISTERED = 'unregistered',
SESSION_SUPERSEDED = 'session_superseded',
INCOMING_CALL = 'line:incoming_call',
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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

---
Expand Down
3 changes: 3 additions & 0 deletions packages/calling/src/CallingClient/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion packages/calling/src/CallingClient/line/ai-docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -207,6 +208,7 @@ export enum LINE_EVENTS {
RECONNECTING = 'reconnecting',
REGISTERED = 'registered',
UNREGISTERED = 'unregistered',
SESSION_SUPERSEDED = 'session_superseded',
INCOMING_CALL = 'line:incoming_call',
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -189,6 +190,7 @@ export enum LINE_EVENTS {
RECONNECTING = 'reconnecting',
REGISTERED = 'registered',
UNREGISTERED = 'unregistered',
SESSION_SUPERSEDED = 'session_superseded',
INCOMING_CALL = 'line:incoming_call',
}
```
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions packages/calling/src/CallingClient/line/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export default class Line extends Eventing<LineEventTypes> implements ILine {
this.emit(event);
break;
case LINE_EVENTS.ERROR:
case LINE_EVENTS.SESSION_SUPERSEDED:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to define new event for just handling this error ? If 409 is received for keepalive, we are to assume that in this tab registration doesn't exist so sending Unregistered event back to the application should be enough. We can add reason for deregistration in the payload for Unregistered event but a new event just for keepalive failure is not required

if (lineError) {
this.emit(event, lineError);
}
Expand Down
52 changes: 52 additions & 0 deletions packages/calling/src/CallingClient/line/line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/calling/src/CallingClient/line/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum LINE_EVENTS {
RECONNECTING = 'reconnecting',
REGISTERED = 'registered',
UNREGISTERED = 'unregistered',
SESSION_SUPERSEDED = 'session_superseded',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed

INCOMING_CALL = 'line:incoming_call',
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

---

Expand All @@ -111,17 +113,20 @@ 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()`
- Resets transient flags (`reconnectPending`, `scheduled429Retry`, `failoverImmediately`, `retryAfter`, `registerRetry`)
- `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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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<br/>→ short-circuit before handleRegistrationErrors
Reg->>Reg: handle409KeepaliveFailure(err, serverType, retryCount)
Reg->>WW: clearKeepaliveTimer() → CLEAR_KEEPALIVE + terminate()
Reg->>Reg: createLineError(SESSION_SUPERSEDED_MESSAGE,<br/>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
Expand Down
Loading
Loading