Skip to content

Commit

Permalink
Recover from timed-out Cast connection
Browse files Browse the repository at this point in the history
When a mobile device goes idle, the Cast connection can be terminated
without explicitly closing it.  When this happens, the Cast session is
unusable and throws exceptions.

This changes CastSender to correctly detect and recover from such a
problem by disconnecting explicitly and dispatching an Error to the
application.

This also fixes the disconnection process so that playback can be
correctly resumed on the local device.

Closes #2446

Change-Id: I59f51a1e911199eee22693e7db4ab39855de0298
  • Loading branch information
joeyparrish committed Apr 13, 2020
1 parent 6a04bde commit 2f6ed0e
Show file tree
Hide file tree
Showing 2 changed files with 132 additions and 13 deletions.
66 changes: 53 additions & 13 deletions lib/cast/cast_sender.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,20 @@ shaka.cast.CastSender = class {
}

this.rejectAllPromises_();

if (shaka.cast.CastSender.session_) {
this.removeListeners_();
shaka.cast.CastSender.session_.stop(() => {}, () => {});

// This can throw if we've already been disconnected somehow.
try {
shaka.cast.CastSender.session_.stop(() => {}, () => {});
} catch (error) {}

shaka.cast.CastSender.session_ = null;
}

// Update casting status.
this.onConnectionStatusChanged_();
}


Expand Down Expand Up @@ -457,13 +466,17 @@ shaka.cast.CastSender = class {
this.nextAsyncCallId_++;
this.asyncCallPromises_[id] = p;

this.sendMessage_({
'type': 'asyncCall',
'targetName': targetName,
'methodName': methodName,
'args': varArgs,
'id': id,
});
try {
this.sendMessage_({
'type': 'asyncCall',
'targetName': targetName,
'methodName': methodName,
'args': varArgs,
'id': id,
});
} catch (error) {
p.reject(error);
}
return p;
}

Expand Down Expand Up @@ -665,12 +678,39 @@ shaka.cast.CastSender = class {
// passed in here were created with quoted property names.

const serialized = shaka.cast.CastUtils.serialize(message);
// TODO: have never seen this fail. When would it and how should we react?
const session = shaka.cast.CastSender.session_;
session.sendMessage(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
serialized,
() => {}, // success callback
shaka.log.error); // error callback

// NOTE: This takes an error callback that we have not seen fire. We don't
// know if it would fire synchronously or asynchronously. Until we know how
// it works, we just log from that callback. But we _have_ seen
// sendMessage() throw synchronously, so we handle that.
try {
session.sendMessage(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
serialized,
() => {}, // success callback
shaka.log.error); // error callback
} catch (error) {
shaka.log.error('Cast session sendMessage threw', error);

// Translate the error
const shakaError = new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.CAST_CONNECTION_TIMED_OUT,
error);

// Dispatch it through the Player proxy
const fakeEvent = new shaka.util.FakeEvent(
'error', {'detail': shakaError});
this.onRemoteEvent_('player', fakeEvent);

// Force this session to disconnect and transfer playback to the local
// device
this.forceDisconnect();

// Throw the translated error from this getter/setter/method to the UI/app
throw shakaError;
}
}
};

Expand Down
79 changes: 79 additions & 0 deletions test/cast/cast_sender_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,85 @@ describe('CastSender', () => {
shaka.util.Error.Code.LOAD_INTERRUPTED));
await expectAsync(p).toBeRejectedWith(expected);
});

it('transfers playback to local device', async () => {
sender.init();
fakeReceiverAvailability(true);
const cast = sender.cast(fakeInitState);
fakeSessionConnection();
await cast;

expect(sender.isCasting()).toBe(true);
expect(onResumeLocal).not.toHaveBeenCalled();

sender.forceDisconnect();

expect(sender.isCasting()).toBe(false);
expect(onResumeLocal).toHaveBeenCalled();
});

it('succeeds even if session.stop() throws', async () => {
sender.init();
fakeReceiverAvailability(true);
const cast = sender.cast(fakeInitState);
fakeSessionConnection();
await cast;

mockSession.stop.and.throwError(new Error('DISCONNECTED!'));

expect(() => sender.forceDisconnect()).not.toThrow(jasmine.anything());
});
});

describe('sendMessage exception', () => {
/** @type {Error} */
let originalException;

/** @type {Object} */
let expectedError;

beforeEach(async () => {
sender.init();
fakeReceiverAvailability(true);
const cast = sender.cast(fakeInitState);
fakeSessionConnection();
await cast;

originalException = new Error('DISCONNECTED!');

expectedError = Util.jasmineError(new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.CAST_CONNECTION_TIMED_OUT,
originalException));

mockSession.sendMessage.and.throwError(originalException);
});

it('propagates to the caller', () => {
expect(() => sender.set('video', 'muted', true)).toThrow(expectedError);
});

it('triggers an error event on Player', () => {
expect(() => sender.set('video', 'muted', true)).toThrow(expectedError);

const expectedEvent = jasmine.objectContaining({
type: 'error',
detail: expectedError,
});

expect(onRemoteEvent).toHaveBeenCalledWith('player', expectedEvent);
});

it('disconnects the sender', () => {
expect(sender.isCasting()).toBe(true);
expect(onResumeLocal).not.toHaveBeenCalled();

expect(() => sender.set('video', 'muted', true)).toThrow(expectedError);

expect(sender.isCasting()).toBe(false);
expect(onResumeLocal).toHaveBeenCalled();
});
});

describe('destroy', () => {
Expand Down

0 comments on commit 2f6ed0e

Please sign in to comment.