You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/modules/copilot/utils/ptt_signalr_http_client.dart

165 lines
5.8 KiB
Dart

import 'dart:async';
import 'dart:developer' as dev;
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:signalr_netcore/errors.dart';
import 'package:signalr_netcore/ihub_protocol.dart';
import 'package:signalr_netcore/signalr_client.dart';
/// SignalR HTTP client used for the push-to-talk LongPolling connection.
///
/// Replaces signalr_netcore's [WebSupportingHttpClient], which has two bugs
/// that break LongPolling on mobile:
///
/// 1. Socket leak / fake abort: it creates a new `http.Client` per request
/// and never closes it. When the hub stops, the pending long-poll is only
/// "aborted" by ignoring its result - the real HTTP socket stays open and
/// the server keeps treating it as an active poll. After a few visits to
/// the screen these orphan connections pile up and new connections hang on
/// "Connecting..." until the app is killed (which closes the sockets).
/// Here every request's client is closed when it completes, and aborting
/// closes the client, which really tears down the socket. [closeAll] kills
/// anything still open when the hub is disposed.
///
/// 2. Poll timeouts close the connection: the transport only ignores its own
/// [TimeoutError], but the HTTP layer throws Dart's [TimeoutException] /
/// [SocketException] on a timeout or network blip, which closes the whole
/// connection. Those are converted to [TimeoutError] for poll (GET)
/// requests so the transport simply re-polls. If failures persist longer
/// than [maxContinuousFailure], the error is rethrown so automatic
/// reconnect takes over.
class PttSignalRHttpClient extends SignalRHttpClient {
PttSignalRHttpClient({
this.maxContinuousFailure = const Duration(seconds: 30),
this.retryDelay = const Duration(seconds: 1),
});
final Duration maxContinuousFailure;
final Duration retryDelay;
final Set<http.Client> _activeClients = {};
DateTime? _firstFailureAt;
bool _closed = false;
void _log(String msg) => dev.log('[PTT-HTTP] $msg');
/// Force-closes every in-flight request (including a pending long-poll).
void closeAll() {
_closed = true;
if (_activeClients.isNotEmpty) {
_log('closeAll: closing ${_activeClients.length} open request(s)');
}
for (final c in _activeClients.toList()) {
try {
c.close();
} catch (_) {}
}
_activeClients.clear();
}
@override
Future<SignalRHttpResponse> send(SignalRHttpRequest request) async {
final isPoll = (request.method ?? '').toUpperCase() == 'GET';
try {
final response = await _send(request);
if (isPoll) _firstFailureAt = null;
return response;
} on TimeoutException {
if (!isPoll) rethrow;
// Normal long-poll timeout: just poll again.
_firstFailureAt = null;
throw TimeoutError('Long poll timed out');
} catch (e) {
if (!isPoll || _closed) rethrow;
final transient = e is SocketException || e is http.ClientException || e is HttpException || e is HandshakeException;
if (!transient) rethrow;
final now = DateTime.now();
_firstFailureAt ??= now;
if (now.difference(_firstFailureAt!) > maxContinuousFailure) {
_firstFailureAt = null;
rethrow; // give up -> connection closes -> automatic reconnect
}
await Future.delayed(retryDelay);
throw TimeoutError('Transient poll failure: $e');
}
}
Future<SignalRHttpResponse> _send(SignalRHttpRequest request) async {
final abortSignal = request.abortSignal;
if (abortSignal != null && (abortSignal.aborted ?? false)) throw AbortError();
if (_closed) throw AbortError('HTTP client closed');
final method = request.method;
final url = request.url;
if (method == null || method.isEmpty) throw ArgumentError('No method defined.');
if (url == null || url.isEmpty) throw ArgumentError('No url defined.');
final uri = Uri.parse(url);
final client = http.Client();
_activeClients.add(client);
var aborted = false;
if (abortSignal != null) {
abortSignal.onabort = () {
aborted = true;
// Closing the client really tears down the underlying socket.
try {
client.close();
} catch (_) {}
};
}
final content = request.content;
final isJson = content is String && content.startsWith('{');
final headers = MessageHeaders()
..setHeaderValue('X-Requested-With', 'FlutterHttpClient')
..setHeaderValue('content-type', isJson ? 'application/json;charset=UTF-8' : 'text/plain;charset=UTF-8');
if (request.headers != null) headers.addMessageHeaders(request.headers);
try {
Future<http.Response> future;
switch (method.toLowerCase()) {
case 'post':
future = client.post(uri, body: content, headers: headers.asMap);
break;
case 'put':
future = client.put(uri, body: content, headers: headers.asMap);
break;
case 'delete':
future = client.delete(uri, body: content, headers: headers.asMap);
break;
case 'get':
default:
future = client.get(uri, headers: headers.asMap);
}
final timeoutMs = request.timeout;
if (timeoutMs != null && timeoutMs > 0) {
future = future.timeout(Duration(milliseconds: timeoutMs));
}
final http.Response resp;
try {
resp = await future;
} catch (_) {
if (aborted) throw AbortError();
rethrow;
}
if (aborted) throw AbortError();
if (resp.statusCode >= 200 && resp.statusCode < 300) {
return SignalRHttpResponse(resp.statusCode, statusText: resp.reasonPhrase, content: resp.body);
}
throw HttpError(resp.reasonPhrase, resp.statusCode);
} finally {
if (abortSignal != null) abortSignal.onabort = null;
_activeClients.remove(client);
try {
client.close();
} catch (_) {}
}
}
}