Files
plezy/test/services/companion_remote_peer_service_test.dart
T

776 lines
28 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/companion_remote/remote_command.dart';
import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart';
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
const _ioTimeout = Duration(seconds: 5);
void main() {
test('auth precondition error uses the active locale', () async {
await LocaleSettings.setLocale(AppLocale.bg);
addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
final peer = CompanionRemotePeerService();
addTearDown(peer.dispose);
await expectLater(
() => peer.createSessionForContexts('Test Host', 'macos', const []),
throwsA(isA<PeerError>().having((error) => error.message, 'message', 'Неуспешно удостоверяване')),
);
});
test('per-source admission rejects before upgrade and recovers exactly one slot', () async {
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 4, maxHostConnectionsPerSource: 2);
final clients = <_RawWebSocketClient>[];
addTearDown(() => _disposeHostAndClients(host, clients));
final port = await _startHost(host);
clients.add(await _RawWebSocketClient.connect(port));
clients.add(await _RawWebSocketClient.connect(port));
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
expect(clients, everyElement(predicate<_RawWebSocketClient>((client) => client.isOpen)));
await clients.removeAt(0).dispose();
clients.add(await _RawWebSocketClient.connect(port));
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
});
test('global admission rejects independently and releases one reservation once', () async {
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 4);
final clients = <_RawWebSocketClient>[];
addTearDown(() => _disposeHostAndClients(host, clients));
final port = await _startHost(host);
clients.add(await _RawWebSocketClient.connect(port));
clients.add(await _RawWebSocketClient.connect(port));
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
await clients.removeAt(0).dispose();
clients.add(await _RawWebSocketClient.connect(port));
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
});
test('authentication timeout releases capacity for a legitimate encrypted session', () async {
final host = CompanionRemotePeerService.forTesting(
maxTotalHostConnections: 1,
maxHostConnectionsPerSource: 1,
authTimeout: const Duration(milliseconds: 300),
);
final remote = CompanionRemotePeerService();
final clients = <_RawWebSocketClient>[];
addTearDown(() async {
await remote.dispose();
await _disposeHostAndClients(host, clients);
});
final port = await _startHost(host);
final silent = await _RawWebSocketClient.connect(port);
clients.add(silent);
expect(await silent.closed.timeout(_ioTimeout), 4001);
clients.remove(silent);
await remote.joinSessionWithContexts(
'Test Remote',
'ios',
'127.0.0.1:$port',
[_authContext],
authContextId: _authContext.id,
expectedHostClientId: _authContext.clientIdentifier,
);
final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
});
test('disconnect closes pending sockets and restart restores full capacity', () async {
final host = CompanionRemotePeerService.forTesting(
maxTotalHostConnections: 2,
maxHostConnectionsPerSource: 2,
authTimeout: const Duration(seconds: 1),
);
final clients = <_RawWebSocketClient>[];
addTearDown(() => _disposeHostAndClients(host, clients));
var port = await _startHost(host);
final firstGeneration = [await _RawWebSocketClient.connect(port), await _RawWebSocketClient.connect(port)];
clients.addAll(firstGeneration);
await host.disconnect();
expect(
await Future.wait(firstGeneration.map((client) => client.closed.timeout(_ioTimeout))),
everyElement(isNot(4001)),
);
clients.removeWhere(firstGeneration.contains);
port = await _startHost(host);
clients.add(await _RawWebSocketClient.connect(port));
clients.add(await _RawWebSocketClient.connect(port));
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
});
test('failed HTTP upgrade and abrupt socket loss both release the only slot', () async {
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 1, maxHostConnectionsPerSource: 1);
final clients = <_RawWebSocketClient>[];
addTearDown(() => _disposeHostAndClients(host, clients));
final port = await _startHost(host);
final httpClient = HttpClient();
addTearDown(() => httpClient.close(force: true));
final request = await httpClient.getUrl(Uri.parse('http://127.0.0.1:$port/ws'));
final response = await request.close();
expect(response.statusCode, HttpStatus.badRequest);
await response.drain<void>();
final afterFailedUpgrade = await _RawWebSocketClient.connect(port);
clients.add(afterFailedUpgrade);
await afterFailedUpgrade.dispose();
clients.remove(afterFailedUpgrade);
final abrupt = await _RawUpgradedSocket.connect(port);
addTearDown(abrupt.dispose);
await abrupt.dispose();
await _flushEventQueue();
clients.add(await _RawWebSocketClient.connect(port));
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
});
test('setup failure after upgrade closes the socket and releases the only slot', () async {
var rejectSetup = true;
final host = CompanionRemotePeerService.forTesting(
maxTotalHostConnections: 1,
maxHostConnectionsPerSource: 1,
afterHostUpgrade: () {
if (!rejectSetup) return;
rejectSetup = false;
throw StateError('injected admission setup failure');
},
);
final clients = <_RawWebSocketClient>[];
addTearDown(() => _disposeHostAndClients(host, clients));
final port = await _startHost(host);
final rejected = await WebSocket.connect('ws://127.0.0.1:$port/ws');
try {
await rejected.drain<void>().timeout(_ioTimeout);
expect(rejected.readyState, WebSocket.closed);
} finally {
await rejected.close();
}
clients.add(await _RawWebSocketClient.connect(port));
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
});
test('oversized and malformed first messages close generically and always restore capacity', () async {
final host = CompanionRemotePeerService.forTesting(
maxTotalHostConnections: 1,
maxHostConnectionsPerSource: 1,
maxPreAuthMessageBytes: 256,
maxFailedAuthAttempts: 20,
);
final clients = <_RawWebSocketClient>[];
final connected = <Object>[];
final commands = <RemoteCommand>[];
final connectedSubscription = host.onDeviceConnected.listen(connected.add);
final commandSubscription = host.onCommandReceived.listen(commands.add);
addTearDown(() async {
await connectedSubscription.cancel();
await commandSubscription.cancel();
await _disposeHostAndClients(host, clients);
});
final port = await _startHost(host);
final cases = <({String name, dynamic payload, int closeCode})>[
(name: 'oversized text', payload: 'x' * 257, closeCode: 4003),
(name: 'oversized UTF-8 text', payload: 'é' * 129, closeCode: 4003),
(name: 'binary input', payload: <int>[1, 2, 3], closeCode: 4003),
(name: 'invalid JSON', payload: '{', closeCode: 4003),
(name: 'non-map JSON', payload: '[]', closeCode: 4003),
(name: 'invalid Base64', payload: _minimalAuth(clientNonce: '%%%'), closeCode: 4003),
(name: 'wrong nonce length', payload: _minimalAuth(clientNonce: base64Encode([1, 2])), closeCode: 4003),
(name: 'missing fields', payload: jsonEncode({'type': 'auth'}), closeCode: 4003),
(
name: 'wrong field type',
payload: jsonEncode({
'type': 'auth',
'authTag': 1,
'clientNonce': base64Encode(List<int>.filled(32, 1)),
'userUUID': 'user-1',
'clientIdentifier': 'remote-client',
'deviceName': 'Remote',
'platform': 'test',
}),
closeCode: 4003,
),
(name: 'unexpected type', payload: jsonEncode({'type': 'ping'}), closeCode: 4002),
];
for (final malformedCase in cases) {
final client = await _RawWebSocketClient.connect(port);
clients.add(client);
client.add(malformedCase.payload);
await client.authFailed.timeout(_ioTimeout);
expect(await client.closed.timeout(_ioTimeout), malformedCase.closeCode, reason: malformedCase.name);
clients.remove(client);
}
final capacityProbe = await _RawWebSocketClient.connect(port);
clients.add(capacityProbe);
expect(capacityProbe.isOpen, isTrue);
expect(connected, isEmpty);
expect(commands, isEmpty);
});
test('malformed first message counts once, ignores a queued valid auth, and locks out before upgrade', () async {
final host = CompanionRemotePeerService.forTesting(
maxTotalHostConnections: 2,
maxHostConnectionsPerSource: 2,
maxFailedAuthAttempts: 2,
);
final clients = <_RawWebSocketClient>[];
final connected = <Object>[];
final subscription = host.onDeviceConnected.listen(connected.add);
addTearDown(() async {
await subscription.cancel();
await _disposeHostAndClients(host, clients);
});
final port = await _startHost(host);
final first = await _RawWebSocketClient.connect(port);
clients.add(first);
final validAuth = _validAuthMessage(first.challenge, _authContext);
first.add('{');
first.add(validAuth);
await first.authFailed.timeout(_ioTimeout);
expect(await first.closed.timeout(_ioTimeout), 4003);
clients.remove(first);
final second = await _RawWebSocketClient.connect(port);
clients.add(second);
second.add(<int>[9]);
await second.authFailed.timeout(_ioTimeout);
expect(await second.closed.timeout(_ioTimeout), 4003);
clients.remove(second);
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
expect(connected, isEmpty);
});
test('unknown context, disallowed user, and bad HMAC share the terminal auth failure path', () async {
final host = CompanionRemotePeerService.forTesting(maxFailedAuthAttempts: 10);
final clients = <_RawWebSocketClient>[];
addTearDown(() => _disposeHostAndClients(host, clients));
final port = await _startHost(host);
final attempts = <String Function(_RawWebSocketClient)>[
(client) => _validAuthMessage(client.challenge, _authContext, authContextId: 'missing-context'),
(client) => _validAuthMessage(client.challenge, _authContext, userUuid: 'disallowed-user'),
(client) => _validAuthMessage(client.challenge, _authContext, authTag: 'invalid-tag'),
];
for (final message in attempts) {
final client = await _RawWebSocketClient.connect(port);
clients.add(client);
client.add(message(client));
await client.authFailed.timeout(_ioTimeout);
expect(await client.closed.timeout(_ioTimeout), 4003);
clients.remove(client);
}
});
test('single-context auth remains compatible without authContextId', () async {
final host = CompanionRemotePeerService.forTesting();
final clients = <_RawWebSocketClient>[];
addTearDown(() => _disposeHostAndClients(host, clients));
final port = await _startHost(host);
final client = await _RawWebSocketClient.connect(port);
clients.add(client);
final connected = host.onDeviceConnected.first;
client.add(_validAuthMessage(client.challenge, _authContext, includeAuthContextId: false));
await connected.timeout(_ioTimeout);
expect(host.isConnected, isTrue);
expect(host.selectedAuthContextId, _authContext.id);
});
test('closed authentication race cannot commit and replacement late close cannot clear the winner', () async {
final secondDerivationEntered = Completer<void>();
final releaseSecondDerivation = Completer<void>();
final secondDerivationReturned = Completer<void>();
var derivationCount = 0;
final host = CompanionRemotePeerService.forTesting(
deriveSessionEncKey: (homeSecret, hostNonce, clientNonce) async {
derivationCount++;
if (derivationCount == 2) {
secondDerivationEntered.complete();
await releaseSecondDerivation.future;
}
final key = await RemoteAuthService.instance.deriveSessionEncKey(homeSecret, hostNonce, clientNonce);
if (derivationCount == 2 && !secondDerivationReturned.isCompleted) {
secondDerivationReturned.complete();
}
return key;
},
);
final clients = <_RawWebSocketClient>[];
final connected = <Object>[];
final subscription = host.onDeviceConnected.listen(connected.add);
addTearDown(() async {
if (!releaseSecondDerivation.isCompleted) releaseSecondDerivation.complete();
await subscription.cancel();
await _disposeHostAndClients(host, clients);
});
final port = await _startHost(host);
final original = await _RawWebSocketClient.connect(port);
clients.add(original);
final originalConnected = host.onDeviceConnected.first;
original.add(_validAuthMessage(original.challenge, _authContext, deviceName: 'Original'));
await originalConnected.timeout(_ioTimeout);
expect(host.isConnected, isTrue);
final racing = await _RawWebSocketClient.connect(port);
clients.add(racing);
racing.add(_validAuthMessage(racing.challenge, _authContext, deviceName: 'Closed racer'));
await secondDerivationEntered.future.timeout(_ioTimeout);
await racing.dispose();
clients.remove(racing);
releaseSecondDerivation.complete();
await secondDerivationReturned.future.timeout(_ioTimeout);
await _flushEventQueue();
expect(host.isConnected, isTrue);
expect(original.isOpen, isTrue);
expect(connected, hasLength(1));
final replacement = await _RawWebSocketClient.connect(port);
clients.add(replacement);
final replacementConnected = host.onDeviceConnected.first;
replacement.add(_validAuthMessage(replacement.challenge, _authContext, deviceName: 'Replacement'));
await replacementConnected.timeout(_ioTimeout);
expect(await original.closed.timeout(_ioTimeout), 4004);
clients.remove(original);
await _flushEventQueue();
expect(host.isConnected, isTrue);
expect(connected, hasLength(2));
});
test('address probes release every source slot before the managed connection', () async {
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 2);
final remote = CompanionRemotePeerService();
addTearDown(() async {
await remote.dispose();
await host.dispose();
});
final port = await _startHost(host);
final address = '127.0.0.1:$port';
final winner = await remote.joinSessionRacingWithContexts(
'Test Remote',
'ios',
[address, address],
[_authContext],
authContextId: _authContext.id,
expectedHostClientId: _authContext.clientIdentifier,
);
expect(winner, address);
final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
});
test('managed join waits for every probe terminal event and listener cleanup', () async {
final closeRequested = List.generate(2, (_) => Completer<void>());
final listenerCleanedUp = List.generate(2, (_) => Completer<void>());
final controllers = List.generate(2, (_) => StreamController<dynamic>(sync: true));
var nextProbe = 0;
final managedJoinStarted = Completer<void>();
late final _ManagedJoinObservingPeer remote;
remote = _ManagedJoinObservingPeer(
raceProbeFactory: (_) {
final index = nextProbe++;
return (
close: () async {
if (!closeRequested[index].isCompleted) {
closeRequested[index].complete();
}
},
ready: Future<void>.value(),
stream: _CancelTrackingStream(controllers[index].stream, () {
if (!listenerCleanedUp[index].isCompleted) {
listenerCleanedUp[index].complete();
}
}),
);
},
onManagedJoin: () async {
expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
managedJoinStarted.complete();
},
);
addTearDown(() async {
for (final controller in controllers) {
if (!controller.isClosed) await controller.close();
}
await remote.dispose();
});
final join = remote.joinSessionRacingWithContexts(
'Test Remote',
'ios',
const ['probe-a', 'probe-b'],
[_authContext],
);
expect(nextProbe, 2);
controllers.first.add(jsonEncode({'type': 'challenge'}));
await closeRequested.first.future.timeout(_ioTimeout);
expect(managedJoinStarted.isCompleted, isFalse);
await controllers.first.close();
await closeRequested.last.future.timeout(_ioTimeout);
expect(listenerCleanedUp.first.isCompleted, isTrue);
expect(managedJoinStarted.isCompleted, isFalse);
await controllers.last.close();
expect(await join.timeout(_ioTimeout), 'probe-a');
expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
expect(managedJoinStarted.isCompleted, isTrue);
});
test('host and remote dispatch encrypted commands through the same contract', () async {
final host = CompanionRemotePeerService();
final remote = CompanionRemotePeerService();
addTearDown(() async {
await remote.dispose();
await host.dispose();
});
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
await remote.joinSessionWithContexts(
'Test Remote',
'ios',
'127.0.0.1:${session.port}',
[_authContext],
authContextId: _authContext.id,
expectedHostClientId: _authContext.clientIdentifier,
);
final hostCommand = host.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.play);
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}));
expect(
await hostCommand.timeout(_ioTimeout),
const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
);
final remoteCommand = remote.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.pause);
host.sendCommand(const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}));
expect(
await remoteCommand.timeout(_ioTimeout),
const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}),
);
});
}
typedef _TestRaceProbeConnection = ({Future<void> Function() close, Future<void> ready, Stream<dynamic> stream});
class _ManagedJoinObservingPeer extends CompanionRemotePeerService {
_ManagedJoinObservingPeer({
required _TestRaceProbeConnection Function(Uri uri) raceProbeFactory,
required this._onManagedJoin,
}) : super.forTesting(raceProbeFactory: raceProbeFactory);
final Future<void> Function() _onManagedJoin;
@override
Future<void> joinSessionWithContexts(
String deviceName,
String platform,
String hostAddress,
List<RemoteAuthContext> authContexts, {
String? authContextId,
String expectedHostClientId = '',
}) {
return _onManagedJoin();
}
}
class _CancelTrackingStream<T> extends Stream<T> {
_CancelTrackingStream(this._delegate, this._onCancel);
final Stream<T> _delegate;
final void Function() _onCancel;
@override
StreamSubscription<T> listen(
void Function(T event)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) {
return _CancelTrackingSubscription(
_delegate.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError),
_onCancel,
);
}
}
class _CancelTrackingSubscription<T> implements StreamSubscription<T> {
_CancelTrackingSubscription(this._delegate, this._onCancel);
final StreamSubscription<T> _delegate;
final void Function() _onCancel;
@override
Future<void> cancel() {
_onCancel();
return _delegate.cancel();
}
@override
void onData(void Function(T data)? handleData) => _delegate.onData(handleData);
@override
void onError(Function? handleError) => _delegate.onError(handleError);
@override
void onDone(void Function()? handleDone) => _delegate.onDone(handleDone);
@override
void pause([Future<void>? resumeSignal]) => _delegate.pause(resumeSignal);
@override
void resume() => _delegate.resume();
@override
bool get isPaused => _delegate.isPaused;
@override
Future<E> asFuture<E>([E? futureValue]) => _delegate.asFuture(futureValue);
}
final _authContext = RemoteAuthContext(
id: 'context-1',
backend: 'plex',
connectionId: 'connection-1',
homeSecret: List<int>.generate(32, (index) => index),
discoveryKey: List<int>.generate(32, (index) => 255 - index),
clientIdentifier: 'host-client',
userUuid: 'user-1',
allowedUserUuids: const ['user-1'],
);
Future<int> _startHost(CompanionRemotePeerService host) async {
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
return session.port;
}
Future<void> _disposeHostAndClients(CompanionRemotePeerService host, List<_RawWebSocketClient> clients) async {
for (final client in List<_RawWebSocketClient>.of(clients)) {
await client.dispose();
}
clients.clear();
await host.dispose();
}
Future<void> _expectWebSocketHandshakeStatus(int port, int statusCode) async {
await expectLater(
WebSocket.connect('ws://127.0.0.1:$port/ws'),
throwsA(
isA<WebSocketException>().having((error) => error.toString(), 'message', contains('status code: $statusCode')),
),
);
}
String _minimalAuth({required String clientNonce}) {
return jsonEncode({
'type': 'auth',
'authTag': 'tag',
'clientNonce': clientNonce,
'userUUID': 'user-1',
'clientIdentifier': 'remote-client',
'deviceName': 'Remote',
'platform': 'test',
});
}
String _validAuthMessage(
Map<String, dynamic> challenge,
RemoteAuthContext context, {
String? authContextId,
String? userUuid,
String? authTag,
String deviceName = 'Raw Remote',
bool includeAuthContextId = true,
}) {
final hostNonce = base64Decode(challenge['nonce'] as String);
final clientNonce = RemoteAuthService.instance.generateNonce();
final selectedUserUuid = userUuid ?? context.userUuid;
final computedAuthTag = RemoteAuthService.instance.computeAuthTag(
homeSecret: context.homeSecret,
hostNonce: hostNonce,
clientNonce: clientNonce,
hostClientId: context.clientIdentifier,
userUUID: selectedUserUuid,
clientIdentifier: 'raw-remote-client',
deviceName: deviceName,
platform: 'test',
);
return jsonEncode({
'type': 'auth',
if (includeAuthContextId) 'authContextId': authContextId ?? context.id,
'clientNonce': base64Encode(clientNonce),
'userUUID': selectedUserUuid,
'clientIdentifier': 'raw-remote-client',
'deviceName': deviceName,
'platform': 'test',
'authTag': authTag ?? computedAuthTag,
});
}
Future<void> _flushEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
class _RawWebSocketClient {
_RawWebSocketClient._(this.socket) {
_subscription = socket.listen(
(data) {
if (data is! String) return;
try {
final decoded = jsonDecode(data);
if (decoded is! Map<String, dynamic>) return;
if (decoded['type'] == 'challenge' && !_challenge.isCompleted) {
_challengeValue = decoded;
_challenge.complete(decoded);
} else if (decoded['type'] == 'authFailed' && !_authFailed.isCompleted) {
_authFailed.complete();
}
} catch (_) {
// Encrypted and malformed server messages are not part of this helper.
}
},
onDone: () {
if (!_closed.isCompleted) _closed.complete(socket.closeCode);
},
onError: (Object error, StackTrace stackTrace) {
if (!_closed.isCompleted) _closed.completeError(error, stackTrace);
},
cancelOnError: true,
);
}
static Future<_RawWebSocketClient> connect(int port) async {
// Ownership transfers to the returned helper.
// ignore: close_sinks
final socket = await WebSocket.connect('ws://127.0.0.1:$port/ws').timeout(_ioTimeout);
final client = _RawWebSocketClient._(socket);
try {
await client._challenge.future.timeout(_ioTimeout);
return client;
} catch (_) {
await client.dispose();
rethrow;
}
}
final WebSocket socket;
final Completer<Map<String, dynamic>> _challenge = Completer<Map<String, dynamic>>();
final Completer<void> _authFailed = Completer<void>();
final Completer<int?> _closed = Completer<int?>();
late final StreamSubscription<dynamic> _subscription;
late Map<String, dynamic> _challengeValue;
Map<String, dynamic> get challenge => _challengeValue;
Future<void> get authFailed => _authFailed.future;
Future<int?> get closed => _closed.future;
bool get isOpen => socket.readyState == WebSocket.open;
void add(dynamic data) => socket.add(data);
Future<void> dispose() async {
if (!_closed.isCompleted) {
try {
await socket.close(WebSocketStatus.normalClosure);
} catch (_) {
// The peer may already have closed the transport.
}
}
if (!_closed.isCompleted) {
await _closed.future.timeout(_ioTimeout);
}
await _subscription.cancel();
}
}
class _RawUpgradedSocket {
_RawUpgradedSocket._(this.socket, this._subscription, this._done);
static Future<_RawUpgradedSocket> connect(int port) async {
final socket = await Socket.connect(InternetAddress.loopbackIPv4, port).timeout(_ioTimeout);
final responseHeaders = StringBuffer();
final upgraded = Completer<void>();
final done = Completer<void>();
late final StreamSubscription<List<int>> subscription;
subscription = socket.listen(
(bytes) {
responseHeaders.write(latin1.decode(bytes));
if (!upgraded.isCompleted && responseHeaders.toString().contains('\r\n\r\n')) {
upgraded.complete();
}
},
onError: (Object error, StackTrace stackTrace) {
if (!upgraded.isCompleted) upgraded.completeError(error, stackTrace);
if (!done.isCompleted) done.complete();
},
onDone: () {
if (!upgraded.isCompleted) {
upgraded.completeError(StateError('Socket closed before WebSocket upgrade'));
}
if (!done.isCompleted) done.complete();
},
);
try {
final key = base64Encode(List<int>.generate(16, (index) => index));
socket.write(
'GET /ws HTTP/1.1\r\n'
'Host: 127.0.0.1:$port\r\n'
'Connection: Upgrade\r\n'
'Upgrade: websocket\r\n'
'Sec-WebSocket-Version: 13\r\n'
'Sec-WebSocket-Key: $key\r\n'
'\r\n',
);
await socket.flush();
await upgraded.future.timeout(_ioTimeout);
expect(responseHeaders.toString(), startsWith('HTTP/1.1 101'));
return _RawUpgradedSocket._(socket, subscription, done);
} catch (_) {
socket.destroy();
await subscription.cancel();
rethrow;
}
}
final Socket socket;
final StreamSubscription<List<int>> _subscription;
final Completer<void> _done;
Future<void> dispose() async {
if (!_done.isCompleted) {
socket.destroy();
await _done.future.timeout(_ioTimeout);
}
await _subscription.cancel();
}
}