improvements
parent
754bd0c939
commit
994acccc9d
@ -0,0 +1,164 @@
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue