Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d301230
feat(client): poll ring state to reconcile a dropped ring outcome
oliverlaz Aug 27, 2026
87db3c6
chore(react-dogfood): honor coordinator_url on the dialer page
oliverlaz Aug 27, 2026
6475cba
chore(client): use the generated GetCallRingStateResponse
oliverlaz Aug 27, 2026
01852a6
refactor(client): share one ring reconciler between events and polling
oliverlaz Aug 27, 2026
5ae14f0
refactor(client): call the ring reconciler straight from the registry
oliverlaz Aug 27, 2026
57cabb1
test(client): pin the ring reconciler's ordering and lifecycle
oliverlaz Aug 28, 2026
6be6cf0
refactor(client): extract the ringing auto-drop into RingTimeout
oliverlaz Aug 28, 2026
21c63d5
refactor(client): move the own-accept ring policy into src/ringing
oliverlaz Aug 28, 2026
410108d
fix(client): keep the ring recoverable when a join attempt fails
oliverlaz Aug 28, 2026
0e500ff
chore(react-dogfood): pin a ring to one call from the URL
oliverlaz Aug 28, 2026
a7b5ef4
fix(client): stop the ring watchdogs before leave() awaits anything
oliverlaz Aug 28, 2026
eab1592
chore(dogfood): add ring state debugging to the RN app
oliverlaz Sep 3, 2026
c2dad3e
Merge branch 'main' into vid-1444-pollable-ring-state
oliverlaz Sep 8, 2026
1c42f3c
feat(client): report which angle caused a ring-driven join
oliverlaz Sep 8, 2026
8e1fbae
fix(client): preserve ringing timeout recovery
oliverlaz Sep 8, 2026
d614092
Merge branch 'main' into vid-1444-pollable-ring-state
oliverlaz Sep 10, 2026
7dbba7d
refactor(client): stop registering a call.missed handler
oliverlaz Sep 10, 2026
280aa46
fix(client): resume the ring state poller instead of rebuilding it
oliverlaz Sep 10, 2026
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
154 changes: 93 additions & 61 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
EndCallResponse,
GetCallReportResponse,
GetCallResponse,
GetCallRingStateResponse,
GetCallSessionParticipantStatsDetailsResponse,
GetOrCreateCallRequest,
GetOrCreateCallResponse,
Expand Down Expand Up @@ -148,14 +149,15 @@ import {
StatsReporter,
Tracer,
} from './stats';
import type { ClientEventReporter, JoinReason } from './reporting';
import type { ClientEventReporter, JoinReason, JoinSource } from './reporting';
import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
import { DynascaleManager } from './helpers/DynascaleManager';
import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
import { ViewportTracker } from './helpers/ViewportTracker';
import { PermissionsContext } from './permissions';
import { RingStatePoller, RingTimeout, resolveOwnRingOutcome } from './ringing';
import { CallTypes } from './CallType';
import { StreamClient } from './coordinator/connection/client';
import { retryInterval, sleep } from './coordinator/connection/utils';
Expand Down Expand Up @@ -298,7 +300,8 @@ export class Call {
private statsReporter?: StatsReporter;
private sfuStatsReporter?: SfuStatsReporter;
private lastStatsOptions?: StatsOptions;
private dropTimeout: ReturnType<typeof setTimeout> | undefined;
private ringTimeout: RingTimeout | undefined;
private ringStatePoller: RingStatePoller | undefined;

private readonly clientState: ClientState;
public readonly streamClient: StreamClient;
Expand Down Expand Up @@ -533,33 +536,20 @@ export class Call {
createSubscription(this.state.session$, (session) => {
if (!this.ringing) return;

const receiverId = this.clientState.connectedUser?.id;
if (!receiverId) return;

const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);

if (isAcceptedByMe || isRejectedByMe) {
this.cancelAutoDrop();
}

const isAcceptedElsewhere =
isAcceptedByMe && this.state.callingState === CallingState.RINGING;
const { settledByMe, leaveReason } = resolveOwnRingOutcome({
session,
currentUserId: this.currentUserId,
callingState: this.state.callingState,
});
if (settledByMe) this.cancelAutoDrop();
if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag)) return;

if (
(isAcceptedElsewhere || isRejectedByMe) &&
!hasPending(this.joinLeaveConcurrencyTag)
) {
globalThis.streamRNVideoSDK?.callingX?.endCall(
this,
isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
this.leave().catch(() => {
this.logger.error(
'Could not leave a call that was accepted or rejected elsewhere',
);
this.leave().catch(() => {
this.logger.error(
'Could not leave a call that was accepted or rejected elsewhere',
);
});
}
});
}),
);
};
Expand Down Expand Up @@ -604,6 +594,7 @@ export class Call {
this.state.setCallingState(CallingState.RINGING);
}
this.scheduleAutoDrop();
this.scheduleRingStatePolling();
this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
}
};
Expand Down Expand Up @@ -709,6 +700,13 @@ export class Call {
throw new Error('Cannot leave call that has already been left.');
}

// before the first await: the calling state stays RINGING well into the
// teardown, so pause both watchdogs before they can race this leave. They
// keep their deadlines, so a failed leave resumes them without handing the
// ring another window.
this.ringTimeout?.pause();
this.ringStatePoller?.pause();

await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
const callingState = this.state.callingState;

Expand Down Expand Up @@ -817,6 +815,7 @@ export class Call {
this.unifiedSessionId = undefined;
this.ringingSubject.next(false);
this.cancelAutoDrop();
this.cancelRingStatePolling();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.clientState.unregisterCall(this);

globalThis.streamRNVideoSDK?.callManager.stop({
Expand Down Expand Up @@ -859,6 +858,17 @@ export class Call {
this.logger.warn('Failed to dispose media engine', err);
});
}
}).catch((err) => {
if (
!hasPending(this.joinLeaveConcurrencyTag) &&
this.state.callingState === CallingState.RINGING
) {
// resume, never re-arm: a fresh watchdog would restart the ring
// window this leave was already most of the way through
this.ringTimeout?.start();
this.ringStatePoller?.resume();
}
throw err;
});
};

Expand Down Expand Up @@ -1052,6 +1062,27 @@ export class Call {
);
};

/**
* Returns who accepted, rejected or missed the ring for a call session.
* Safe to poll: it performs no writes and emits no events.
*
* @param callSessionId the call session to read. Defaults to the current one.
* Pass it explicitly to read a session that has already ended, as ending a
* call clears its current session.
*/
getRingState = async (
callSessionId?: string,
): Promise<GetCallRingStateResponse> => {
const sessionId = callSessionId ?? this.state.session?.id;
if (!sessionId) {
throw new Error('Cannot read the ring state: the call has no session');
}
return this.streamClient.get<GetCallRingStateResponse>(
`${this.streamClientBasePath}/ring_state`,
{ call_session_id: sessionId },
);
Comment thread
oliverlaz marked this conversation as resolved.
};

/**
* A shortcut for {@link Call.get} with `notify` parameter set to `true`.
* Will send a `call.notification` event to the call members.
Expand Down Expand Up @@ -1108,12 +1139,14 @@ export class Call {
joinResponseTimeout,
rpcRequestTimeout,
allowOwnTracksLoopback = false,
joinSource,
...data
}: JoinCallData & {
maxJoinRetries?: number;
joinResponseTimeout?: number;
rpcRequestTimeout?: number;
allowOwnTracksLoopback?: boolean;
joinSource?: JoinSource;
} = {}): Promise<void> => {
const callingState = this.state.callingState;

Expand Down Expand Up @@ -1155,7 +1188,7 @@ export class Call {
try {
await this.clientEventReporter.withJoinLifecycle(
this.cid,
'first-attempt',
{ joinReason: 'first-attempt', joinSource },
async () => {
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
try {
Expand Down Expand Up @@ -2077,8 +2110,10 @@ export class Call {
this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
? 'network-available'
: 'full-rejoin';
await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () =>
this.doJoin(this.joinCallData),
await this.clientEventReporter.withJoinLifecycle(
this.cid,
{ joinReason },
() => this.doJoin(this.joinCallData),
);
await this.restorePublishedTracks();
this.restoreSubscribedTracks();
Expand Down Expand Up @@ -2113,7 +2148,7 @@ export class Call {
const currentSfu = currentSfuClient.edgeName;
await this.clientEventReporter.withJoinLifecycle(
this.cid,
'migration',
{ joinReason: 'migration' },
() =>
this.doJoin({
...this.joinCallData,
Expand Down Expand Up @@ -3110,42 +3145,39 @@ export class Call {
*/
private scheduleAutoDrop = () => {
this.cancelAutoDrop();

const settings = this.state.settings;
if (!settings) return;
// ignore if the call is not ringing
if (this.state.callingState !== CallingState.RINGING) return;

const timeoutInMs = this.isCreatedByMe
? settings.ring.auto_cancel_timeout_ms
: settings.ring.incoming_call_timeout_ms;

// 0 means no auto-drop
if (timeoutInMs <= 0) return;
this.dropTimeout = setTimeout(() => {
// the call might have stopped ringing by this point,
// e.g. it was already accepted and joined
if (this.state.callingState !== CallingState.RINGING) return;
this.leave({
reject: true,
reason: 'timeout',
message: `ringing timeout - ${
this.isCreatedByMe
? 'no one accepted'
: `user didn't interact with incoming call screen`
}`,
}).catch((err) => {
this.logger.error('Failed to drop call', err);
});
}, timeoutInMs);
this.ringTimeout = new RingTimeout(this);
this.ringTimeout.start();
};

/**
* Cancels a scheduled auto-drop timeout.
*/
private cancelAutoDrop = () => {
clearTimeout(this.dropTimeout);
this.dropTimeout = undefined;
this.ringTimeout?.stop();
this.ringTimeout = undefined;
};

/**
* Starts polling for the ring outcome. Applicable only to ringing calls the
* current user created.
*/
private scheduleRingStatePolling = () => {
this.cancelRingStatePolling();

if (!this.isCreatedByMe) return;
const options = this.streamClient.options.ringStatePolling;
if (options === false) return;

this.ringStatePoller = new RingStatePoller(this, options);
this.ringStatePoller.start();
};

/**
* Cancels the ring state polling.
*/
private cancelRingStatePolling = () => {
this.ringStatePoller?.stop();
this.ringStatePoller = undefined;
};

/**
Expand Down
Loading
Loading