From 10a0be48060f0d14f2ec37a3fbe071530904c1c5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 13:20:45 -0500 Subject: [PATCH 01/20] test: share a tunnel server fixture TunnelServer accepts any number of clients, hands back each tunnel end in order, and can park a handshake at the greeting, at the CONNECT reply, or part-way through the server's TLS flight. It can also stop accepting and fill its backlog so a later TCP connect stays pending. peer_eof_test uses it instead of its own proxy. --- test/helpers/tunnel_peer.dart | 238 ++++++++++++++++++++++++++++++++++ test/peer_eof_test.dart | 85 ++---------- 2 files changed, 246 insertions(+), 77 deletions(-) create mode 100644 test/helpers/tunnel_peer.dart diff --git a/test/helpers/tunnel_peer.dart b/test/helpers/tunnel_peer.dart new file mode 100644 index 0000000..a2e6a5c --- /dev/null +++ b/test/helpers/tunnel_peer.dart @@ -0,0 +1,238 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:socks_socket/socks_socket.dart'; +import 'package:socks_socket/src/connection_socket.dart'; +import 'package:test/test.dart'; + +import 'test_certificates.dart'; + +const tunnelDeadline = Duration(seconds: 5); + +class TunnelPeer { + final Socket socket; + final bytes = BytesBuilder(copy: false); + final firstChunk = Completer(); + final done = Completer(); + late final StreamSubscription> input; + + TunnelPeer(this.socket) { + input = socket.listen((chunk) { + bytes.add(chunk); + if (!firstChunk.isCompleted) firstChunk.complete(); + }, onError: (Object error, StackTrace stack) { + done.completeError(error, stack); + }, onDone: () { + if (!done.isCompleted) done.complete(); + }); + done.future.ignore(); + } +} + +/// A point where [TunnelServer] waits until the test lets it go on. +class TunnelHold { + final reached = Completer(); + final release = Completer(); + + /// For [TunnelServer.holdTls]: how many bytes of the server's handshake + /// reply reach the client before the hold. + final int after; + + TunnelHold({this.after = 0}); + + Future _wait() { + if (!reached.isCompleted) reached.complete(); + return release.future; + } +} + +/// A SOCKS5 server that accepts every CONNECT and hands back the tunnel end. +class TunnelServer { + final RawServerSocket _server; + final TestCertificates? certificates; + final _peers = StreamController(); + late final _accepted = StreamIterator(_peers.stream); + late final StreamSubscription _accepting; + late final int port = _server.port; + RawServerSocket? _blocked; + final _fillers = >[]; + int connections = 0; + + /// Delays the greeting reply, so a client stays in connect(). + TunnelHold? holdGreeting; + + /// Delays the CONNECT reply, so a client stays in connectTo(). + TunnelHold? holdConnect; + + /// Stalls the TLS handshake after [TunnelHold.after] bytes of the server's + /// reply, so a client stays in connectTo() after the SOCKS reply. + TunnelHold? holdTls; + + TunnelServer._(this._server, this.certificates) { + _accepting = _server.listen(_serve); + } + + bool get tls => certificates != null; + + static Future start({bool tls = false}) async { + final raw = await RawServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final server = + TunnelServer._(raw, tls ? TestCertificates.generate() : null); + addTearDown(server.close); + return server; + } + + Future _serve(RawSocket raw) async { + connections++; + addTearDown(raw.close); + try { + final channel = RawChannel(raw); + final greeting = await channel.read(2); + await channel.read(greeting[1]); + await holdGreeting?._wait(); + await channel.write([5, 0]); + final request = await channel.read(5); + await channel.read(request[4] + 2); + await holdConnect?._wait(); + await channel.write([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); + final Socket transport; + final hold = holdTls; + if (hold != null) { + transport = await _stalledTls(channel, hold); + } else if (tls) { + final secured = await RawSecureSocket.secureServer( + raw, + certificates!.serverContext(), + subscription: channel.detach(), + ); + transport = RawChannel(secured).socket(); + } else { + transport = channel.socket(); + } + addTearDown(transport.destroy); + _peers.add(TunnelPeer(transport)); + } catch (_) { + // The client gave up on this connection; nextPeer() times out if a + // test expected it. + raw.close(); + } + } + + /// Relays the TLS handshake through a local server and withholds its reply + /// after [TunnelHold.after] bytes until the hold is released. + Future _stalledTls(RawChannel channel, TunnelHold hold) async { + final backend = await SecureServerSocket.bind( + InternetAddress.loopbackIPv4, 0, certificates!.serverContext()); + addTearDown(backend.close); + final accepted = backend.first; + accepted.ignore(); + final front = channel.socket(); + final upstream = + await Socket.connect(InternetAddress.loopbackIPv4, backend.port); + addTearDown(upstream.destroy); + front.listen(upstream.add, + onError: (Object _) => upstream.destroy(), onDone: upstream.destroy); + var forwarded = 0; + late final StreamSubscription> reply; + reply = upstream.listen((chunk) async { + final held = hold.reached.isCompleted; + final keep = held ? chunk.length : hold.after - forwarded; + if (keep > 0) front.add(chunk.sublist(0, keep)); + forwarded += keep; + if (held) return; + reply.pause(); + await hold._wait(); + if (keep < chunk.length) front.add(chunk.sublist(keep)); + reply.resume(); + }, onError: (Object _) => front.destroy(), onDone: front.destroy); + return accepted; + } + + /// Replaces the listener with one that never accepts and fills its + /// backlog, so a later TCP connect to [port] stays pending. Returns false + /// when this host completes such connects anyway. + Future stall() async { + await _accepting.cancel(); + await _server.close(); + try { + _blocked = await RawServerSocket.bind(InternetAddress.loopbackIPv4, port, + backlog: 1); + } on SocketException { + return false; + } + for (var i = 0; i < 4; i++) { + final task = + await Socket.startConnect(InternetAddress.loopbackIPv4, port); + _fillers.add(task); + final connected = task.socket + .then((_) => true, onError: (Object _) => false) + .timeout(const Duration(milliseconds: 300), onTimeout: () => false); + if (!await connected) return true; + } + return false; + } + + /// The next tunnel the server completed. + Future nextPeer() async { + if (!await _accepted.moveNext().timeout(tunnelDeadline)) { + throw StateError('Tunnel server closed'); + } + return _accepted.current; + } + + Future createClient({ + Duration handshakeTimeout = const Duration(seconds: 30), + Duration operationTimeout = tunnelDeadline, + }) async { + final client = await SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: port, + sslEnabled: tls, + securityContext: certificates?.clientContext(), + handshakeTimeout: handshakeTimeout, + operationTimeout: operationTimeout, + ); + addTearDown(() => client.close().catchError((_) {})); + return client; + } + + /// A connected client and its tunnel end. + Future<(SOCKSSocket, TunnelPeer)> connect({ + Duration handshakeTimeout = const Duration(seconds: 30), + Duration operationTimeout = tunnelDeadline, + }) async { + final client = await createClient( + handshakeTimeout: handshakeTimeout, + operationTimeout: operationTimeout, + ); + await client.connect(); + await client.connectTo('localhost', 443); + return (client, await nextPeer()); + } + + Future close() async { + for (final hold in [holdGreeting, holdConnect, holdTls]) { + if (hold != null && !hold.release.isCompleted) hold.release.complete(); + } + for (final filler in _fillers) { + filler.cancel(); + filler.socket.then((s) => s.destroy(), onError: (Object _) {}); + } + await _server.close(); + await _blocked?.close(); + await _accepted.cancel(); + // Without a listener the controller's close() would never complete. + _peers.close().ignore(); + } +} + +Future<(SOCKSSocket, TunnelPeer)> connectTunnel({ + bool tls = false, + Duration operationTimeout = tunnelDeadline, +}) async { + final server = await TunnelServer.start(tls: tls); + return server.connect( + operationTimeout: operationTimeout, + ); +} diff --git a/test/peer_eof_test.dart b/test/peer_eof_test.dart index 029e629..6da9b42 100644 --- a/test/peer_eof_test.dart +++ b/test/peer_eof_test.dart @@ -3,89 +3,20 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:socks_socket/socks_socket.dart'; -import 'package:socks_socket/src/connection_socket.dart'; import 'package:test/test.dart'; -import 'helpers/test_certificates.dart'; +import 'helpers/tunnel_peer.dart'; -const _deadline = Duration(seconds: 5); +const _deadline = tunnelDeadline; // SOL_SOCKET, SO_SNDBUF and SO_LINGER differ between Linux and macOS. final _solSocket = Platform.isMacOS ? 0xffff : 1; final _soSndbuf = Platform.isMacOS ? 0x1001 : 7; final _soLinger = Platform.isMacOS ? 0x80 : 13; -class _Peer { - final Socket socket; - final bytes = BytesBuilder(copy: false); - final firstChunk = Completer(); - final done = Completer(); - late final StreamSubscription> input; - - _Peer(this.socket) { - input = socket.listen((chunk) { - bytes.add(chunk); - if (!firstChunk.isCompleted) firstChunk.complete(); - }, onError: (Object error, StackTrace stack) { - done.completeError(error, stack); - }, onDone: () { - if (!done.isCompleted) done.complete(); - }); - done.future.ignore(); - } -} - -Future<(SOCKSSocket, _Peer)> _connect({ - bool tls = false, - Duration operationTimeout = _deadline, -}) async { - final certificates = tls ? TestCertificates.generate() : null; - final server = await RawServerSocket.bind(InternetAddress.loopbackIPv4, 0); - addTearDown(server.close); - final accepted = Completer<_Peer>(); - server.listen((raw) async { - addTearDown(raw.close); - try { - final channel = RawChannel(raw); - final greeting = await channel.read(2); - await channel.read(greeting[1]); - await channel.write([5, 0]); - final request = await channel.read(5); - await channel.read(request[4] + 2); - await channel.write([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); - final Socket transport; - if (tls) { - final secured = await RawSecureSocket.secureServer( - raw, - certificates!.serverContext(), - subscription: channel.detach(), - ); - transport = RawChannel(secured).socket(); - } else { - transport = channel.socket(); - } - addTearDown(transport.destroy); - accepted.complete(_Peer(transport)); - } catch (error, stack) { - accepted.completeError(error, stack); - } - }); - final client = await SOCKSSocket.create( - proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: server.port, - sslEnabled: tls, - securityContext: certificates?.clientContext(), - operationTimeout: operationTimeout, - ); - addTearDown(() => client.close().catchError((_) {})); - await client.connect(); - await client.connectTo('localhost', 443); - return (client, await accepted.future.timeout(_deadline)); -} - void main() { test('peer EOF drains a pending underlying socket flush', () async { - final (client, peer) = await _connect(); + final (client, peer) = await connectTunnel(); final eof = client.inputStream.drain(); // Linux SO_SNDBUF: force the upload to remain pending while reads pause. client.socket.setRawOption(RawSocketOption.fromInt(1, 7, 4096)); @@ -108,7 +39,7 @@ void main() { for (final tls in [false, true]) { test('peer EOF drains underlying socket.addStream (TLS=$tls)', () async { - final (client, peer) = await _connect(tls: tls); + final (client, peer) = await connectTunnel(tls: tls); final eof = client.inputStream.drain(); final source = StreamController>(); addTearDown(source.close); @@ -127,7 +58,7 @@ void main() { }); test('peer EOF drains an accepted outputStream (TLS=$tls)', () async { - final (client, peer) = await _connect(tls: tls); + final (client, peer) = await connectTunnel(tls: tls); final eof = client.inputStream.drain(); final source = StreamController>(); addTearDown(source.close); @@ -155,7 +86,7 @@ void main() { test('an output sink first used after peer EOF rejects new uploads', () async { - final (client, peer) = await _connect(); + final (client, peer) = await connectTunnel(); final eof = client.inputStream.drain(); await peer.socket.close(); await eof.timeout(_deadline); @@ -169,7 +100,7 @@ void main() { test('reset after peer EOF cancels a paused underlying upload source', () async { - final (client, peer) = await _connect(); + final (client, peer) = await connectTunnel(); final eof = client.inputStream.drain(); final source = StreamController>(); addTearDown(() async { @@ -202,7 +133,7 @@ void main() { for (final direct in [false, true]) { test('close times out when an upload never ends (direct=$direct)', () async { - final (client, peer) = await _connect( + final (client, peer) = await connectTunnel( operationTimeout: const Duration(milliseconds: 100), ); final eof = client.inputStream.drain(); From aca2dae658a117c85b271b6dea27e078688a6693 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 16:03:26 -0500 Subject: [PATCH 02/20] fix: fail a channel read or write at once after a synchronous socket error dart:io can report a failed read or write through the socket's error handler before the call returns, then close the socket. A plain socket does so for a read that fails, which is how macOS surfaces a reset by the peer (Linux usually raises an error event first, which the channel already handled), and a secure socket does so for a write after the connection closed. RawChannel checked for a closed channel only before each read or write, so after such a failure it created a waiter that no event would ever complete, and a SOCKS handshake cut short by a reset waited for its deadline. Check again after the call, before waiting. SocksConnection reads through the same channel and is fixed too. --- CHANGELOG.md | 4 ++ lib/src/connection_socket.dart | 24 +++++---- test/connection_socket_test.dart | 91 ++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb0f8b..b155432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Fail a handshake read or write at once when the socket reports its error synchronously, as macOS does for a reset peer, instead of waiting for the handshake deadline. `SocksConnection` shares the fix. + ## 1.4.0 - Add `SocksConnection.start` for `HttpClient.connectionFactory` (Dart 3.5). diff --git a/lib/src/connection_socket.dart b/lib/src/connection_socket.dart index 6e82463..3b73751 100644 --- a/lib/src/connection_socket.dart +++ b/lib/src/connection_socket.dart @@ -79,14 +79,15 @@ class RawChannel { final bytes = Uint8List(count); var offset = 0; while (offset < count) { - if (_closed) { - throw _error ?? const SocketException('SOCKS transport closed'); - } + _checkOpen(); final chunk = raw.read(count - offset); if (chunk != null) { bytes.setRange(offset, offset + chunk.length, chunk); offset += chunk.length; } else { + // A failed read reports its error, and closes this channel, before + // returning; a waiter created now would never be completed. + _checkOpen(); if (_readClosed) { throw const SocketException('Incomplete SOCKS response'); } @@ -101,22 +102,27 @@ class RawChannel { Future write(List bytes) async { var offset = 0; while (offset < bytes.length) { - if (_closed) { - throw _error ?? const SocketException('SOCKS transport closed'); - } + _checkOpen(); offset += raw.write(bytes, offset); + // A secure socket reports a failed write synchronously, closing this + // channel before write() returns; a plain socket defers the report. + _checkOpen(); // Plain sockets may buffer the send; wait until writable before flushing. if (offset < bytes.length || raw is! RawSecureSocket) { final ready = _writable = Completer(); raw.writeEventsEnabled = true; await ready.future; - if (_closed) { - throw _error ?? const SocketException('SOCKS transport closed'); - } + _checkOpen(); } } } + void _checkOpen() { + if (_closed) { + throw _error ?? const SocketException('SOCKS transport closed'); + } + } + StreamSubscription detach() { _detached = true; raw.readEventsEnabled = false; diff --git a/test/connection_socket_test.dart b/test/connection_socket_test.dart index 53fe48f..ea9170a 100644 --- a/test/connection_socket_test.dart +++ b/test/connection_socket_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:socks_socket/src/connection_socket.dart'; import 'package:test/test.dart'; @@ -53,6 +54,73 @@ class _PendingWriteSocket extends Stream implements RawSocket { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +/// A socket that fails its next read or write synchronously: dart:io delivers +/// the error through the event stream and closes the socket before the call +/// returns, so no event follows. A plain socket reports a read failure this +/// way, which is how macOS surfaces a reset peer, and a secure socket reports +/// a write after the connection closed this way. +class _ResetSocket extends Stream implements RawSocket { + final events = StreamController(sync: true); + var reads = 0; + + @override + bool writeEventsEnabled = false; + bool _readEventsEnabled = false; + + @override + bool get readEventsEnabled => _readEventsEnabled; + + @override + set readEventsEnabled(bool enabled) { + _readEventsEnabled = enabled; + if (enabled) { + scheduleMicrotask(() { + if (_readEventsEnabled && events.hasListener) { + events.add(RawSocketEvent.read); + } + }); + } + } + + @override + StreamSubscription listen( + void Function(RawSocketEvent)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + events.stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + + @override + Uint8List? read([int? len]) { + // Nothing to read the first time; the reset arrives with the read event. + if (reads++ == 0) return null; + _reset('Read failed'); + return null; + } + + @override + int write(List buffer, [int offset = 0, int? count]) { + _reset('Write failed'); + return 0; + } + + void _reset(String what) { + events.addError(SocketException('$what: Connection reset by peer')); + scheduleMicrotask(() { + if (events.hasListener) events.add(RawSocketEvent.closed); + events.close(); + }); + } + + @override + Future close() async => this; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { test('flush followed by close delivers the complete TCP payload', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); @@ -102,4 +170,27 @@ void main() { socket.destroy(); await flushed.timeout(const Duration(seconds: 2)); }); + + test('a read fails at once when the socket reports a reset synchronously', + () async { + final raw = _ResetSocket(); + final channel = RawChannel(raw); + await expectLater( + channel.read(2).timeout(const Duration(seconds: 2)), + throwsA(isA() + .having((e) => e.message, 'message', contains('reset')))); + expect(raw.reads, 2); + expect(channel.isClosed, isTrue); + }); + + test('a write fails at once when the socket reports a reset synchronously', + () async { + final raw = _ResetSocket(); + final channel = RawChannel(raw); + await expectLater( + channel.write([1, 2, 3]).timeout(const Duration(seconds: 2)), + throwsA(isA() + .having((e) => e.message, 'message', contains('reset')))); + expect(channel.isClosed, isTrue); + }); } From b63b558bf149fe9e3787523c518a5d309e002a91 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 13:20:50 -0500 Subject: [PATCH 03/20] fix: end a reconnect or TLS handshake that close() interrupts reconnect() closed the old connection, then reset the close state and dialled again. A close() during that window shared the old close and was then forgotten: the reconnect finished and the socket ended connected. Record that a close was requested and stop the reconnect at its next step. The proxy TCP connect is a cancellable ConnectionTask, so close() ends it too, and a connect that fails after close was requested reports the cancellation rather than its own error. The task exists only a few microtasks after the old connection's close completes; a close() requested in that gap, for instance from the old inputStream's done event, is applied to the task as soon as it exists. When the raw socket closes mid-handshake with part of the server's flight still buffered, dart:io's secure socket never fails the pending handshake, so connectTo() waited for the handshake deadline, thirty seconds by default. Complete the cancel signal from close() as cancel() does, so the handshake ends at once. Also document that reconnect() works after cancel() once a target is known; the instance is not spent. --- CHANGELOG.md | 2 + README.md | 2 +- lib/socks_socket.dart | 85 +++++++++++++---- test/teardown_race_test.dart | 171 +++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 test/teardown_race_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b155432..1299978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased +- `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. - Fail a handshake read or write at once when the socket reports its error synchronously, as macOS does for a reset peer, instead of waiting for the handshake deadline. `SocksConnection` shares the fix. +- Document that `reconnect()` works after `cancel()`. ## 1.4.0 diff --git a/README.md b/README.md index c84eb47..cce72b8 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ await socksSocket.write('{"jsonrpc":"2.0","method":"server.ping","id":1}', newline: true); ``` -`cancel()` or `close()` during connect spends the instance; create a new one. Peer close is noticed, and `state` updated, only while `inputStream` has a listener. +After `cancel()` or `close()` during connect, `reconnect()` opens a new connection once a target is known. `reconnect()` closes the current connection first, which ends `inputStream`; a `close()` or `destroy()` while it is in progress cancels it, including one made from that stream's `onDone`. Peer close is noticed, and `state` updated, only while `inputStream` has a listener. ## HttpClient Connections diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 043f02e..508ffb3 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -245,6 +245,10 @@ class SOCKSSocket { StackTrace? _writeFailureStack; Future? _closeFuture; bool _closing = false; + + /// Set by [close] and [destroy]; a [reconnect] in flight stops at its next + /// step instead of opening a connection the caller has already given up. + bool _closeRequested = false; bool _peerReadClosed = false; /// Accept bad certificates (testing only). @@ -254,6 +258,9 @@ class SOCKSSocket { /// Cancel signal for in-flight operations. Completer? _cancelCompleter; + + /// The proxy TCP connect in flight, so [close] and [destroy] can end it. + ConnectionTask? _connectTask; bool _cancelled = false; bool _greetingStarted = false; bool _greetingComplete = false; @@ -376,20 +383,12 @@ class SOCKSSocket { _requestStarted = false; _peerReadClosed = false; if (sslEnabled) { - final raw = await RawSocket.connect( - proxyHost, - proxyPort, - timeout: _handshakeTimeout, - ); + final raw = await _connectProxy(RawSocket.startConnect); _channel = RawChannel(raw); _socksSocket = _channel!.socket(); } else { // Keep a native Socket so callers can use SecureSocket.secure(socket). - _socksSocket = await Socket.connect( - proxyHost, - proxyPort, - timeout: _handshakeTimeout, - ); + _socksSocket = await _connectProxy(Socket.startConnect); _nativeSocketOpen = true; _nativeSocketNeedsDestroy = true; _socksSocket.done.then((_) { @@ -428,11 +427,28 @@ class SOCKSSocket { ); } + Future _connectProxy( + Future> Function(String host, int port) start) async { + final task = _connectTask = await start(proxyHost, proxyPort); + // A close() or destroy() while the task was being created found + // nothing to cancel yet. + if (_closeRequested) task.cancel(); + try { + return await task.socket.timeout(_handshakeTimeout, onTimeout: () { + task.cancel(); + throw SocketException( + 'Connection timed out, host: $proxyHost, port: $proxyPort'); + }); + } finally { + _connectTask = null; + } + } + /// The peer stopped sending; writes already accepted are still delivered. void _peerClosed() { _peerReadClosed = true; _state = SocksSocketState.disconnected; - close().ignore(); + _ensureClosed().ignore(); } void _destroyTransport() { @@ -808,7 +824,12 @@ class SOCKSSocket { /// /// Returns: /// A Future that resolves to void. - Future close() => _closeFuture ??= _close(); + Future close() { + _closeRequested = true; + return _ensureClosed(); + } + + Future _ensureClosed() => _closeFuture ??= _close(); Future _flushBeforeClose(Socket transport) async { final elapsed = Stopwatch()..start(); @@ -836,8 +857,17 @@ class SOCKSSocket { } } + /// Ends a connect in flight. The TLS handshake cannot notice a destroyed + /// transport on its own, so it waits for this or for its deadline. + void _signalCancel(SocksCancelledException error) { + _connectTask?.cancel(); + final cancel = _cancelCompleter; + if (cancel != null && !cancel.isCompleted) cancel.completeError(error); + } + Future _close() async { _closing = true; + _signalCancel(_closedBeforeConnected()); final upgraded = sslEnabled && _sslUpgraded; final channel = upgraded ? _secureChannel : _channel; var flushed = false; @@ -875,7 +905,8 @@ class SOCKSSocket { } /// Cancels an in-flight connect operation. No-op if not connecting. - /// The instance cannot be reused afterwards; create a new socket. + /// Once a target is known, [reconnect] opens a new connection; to end a + /// reconnect in progress, use [close] or [destroy]. Future cancel() async { if (_state != SocksSocketState.connecting) return; @@ -911,6 +942,10 @@ class SOCKSSocket { /// Reconnects to the previously connected target. /// + /// Closes the current connection first, which ends [inputStream]. A [close] + /// or [destroy] while the reconnect is in progress cancels it with + /// [SocksCancelledException], including one made from that stream's onDone. + /// /// Throws [StateError] if [connectTo] was never called. Future reconnect({String? isolationToken}) async { if (_reconnecting) throw StateError('Reconnect is already in progress'); @@ -933,11 +968,13 @@ class SOCKSSocket { _isolationToken = isolationToken; } + _closeRequested = false; try { - await close(); + await _ensureClosed(); } catch (_) { // Connection may already be broken. } + _checkReconnectAborted(); // Broadcast controllers can't be reused after close. _pendingApplicationData = null; @@ -952,17 +989,31 @@ class SOCKSSocket { try { await _init(); + _checkReconnectAborted(); await _connect(); + _checkReconnectAborted(); await _connectTo(_targetDomain!, _targetPort!); + _checkReconnectAborted(); } catch (error) { - _state = error is SocksCancelledException - ? SocksSocketState.disconnected - : SocksSocketState.error; + final cancelled = error is SocksCancelledException || _closeRequested; + _state = + cancelled ? SocksSocketState.disconnected : SocksSocketState.error; _abandonConnection(); + // A cancelled TCP connect fails with a SocketException. + if (cancelled && error is! SocksCancelledException) { + throw _reconnectCancelled(); + } rethrow; } } + void _checkReconnectAborted() { + if (_closeRequested) throw _reconnectCancelled(); + } + + SocksCancelledException _reconnectCancelled() => SocksCancelledException( + message: 'SOCKS5 reconnect cancelled: the connection was closed.'); + StreamSubscription> listen( void Function(List data)? onData, { Function? onError, diff --git a/test/teardown_race_test.dart b/test/teardown_race_test.dart new file mode 100644 index 0000000..ce3ea7c --- /dev/null +++ b/test/teardown_race_test.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:socks_socket/socks_socket.dart'; +import 'package:test/test.dart'; + +import 'helpers/tunnel_peer.dart'; + +/// close() and destroy() racing a reconnect, a TLS handshake, or each other. +void main() { + final teardowns = Function(SOCKSSocket)>{ + 'close()': (client) => client.close(), + }; + + for (final tls in [false, true]) { + for (final MapEntry(key: name, value: teardown) in teardowns.entries) { + group('$name during reconnect (TLS=$tls)', () { + test('right after it starts', () async { + final server = await TunnelServer.start(tls: tls); + final (client, peer) = await server.connect(); + final reconnecting = client.reconnect(); + final failed = expectLater( + reconnecting, throwsA(isA())); + final tearingDown = teardown(client); + await failed.timeout(tunnelDeadline); + await tearingDown.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + await expectLater(client.write('A'), throwsStateError); + await peer.done.future.timeout(tunnelDeadline); + expect(server.connections, 1); + await client.close().timeout(tunnelDeadline); + }); + + test('while the proxy answers the greeting', () async { + final server = await TunnelServer.start(tls: tls); + final (client, peer) = await server.connect(); + await client.write('before'); + await peer.firstChunk.future.timeout(tunnelDeadline); + final hold = server.holdGreeting = TunnelHold(); + final reconnecting = client.reconnect(); + final failed = expectLater( + reconnecting, throwsA(isA())); + await hold.reached.future.timeout(tunnelDeadline); + expect(server.connections, 2); + final tearingDown = teardown(client); + await failed.timeout(tunnelDeadline); + await tearingDown.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + hold.release.complete(); + // The abandoned handshake must not connect the socket after all. + await Future.delayed(const Duration(milliseconds: 200)); + expect(client.state, SocksSocketState.disconnected); + await expectLater(client.write('after'), throwsStateError); + await client.close().timeout(tunnelDeadline); + }); + + test('while the proxy TCP connect is pending', () async { + final server = await TunnelServer.start(tls: tls); + final (client, peer) = await server.connect( + handshakeTimeout: const Duration(seconds: 2)); + if (!await server.stall()) { + markTestSkipped('This host completes connects beyond the backlog'); + return; + } + final reconnecting = client.reconnect(); + final failed = expectLater( + reconnecting, throwsA(isA())); + await peer.done.future.timeout(tunnelDeadline); + await Future.delayed(const Duration(milliseconds: 100)); + final stopwatch = Stopwatch()..start(); + final tearingDown = teardown(client); + await failed.timeout(tunnelDeadline); + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 1))); + await tearingDown.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + await expectLater(client.write('A'), throwsStateError); + await client.close().timeout(tunnelDeadline); + }); + + test('from the old inputStream ending', () async { + // reconnect() creates the connect task a few microtasks after the + // old connection's close completes, and the old inputStream's done + // event is delivered in that gap. A teardown from there finds no + // task to cancel yet, so sweep a few microtasks after the event. + const microtasks = 4; + final server = await TunnelServer.start(tls: tls); + final clients = [ + for (var i = 0; i < microtasks; i++) + await server.connect(handshakeTimeout: const Duration(seconds: 2)) + ]; + if (!await server.stall()) { + markTestSkipped('This host completes connects beyond the backlog'); + return; + } + for (var delay = 0; delay < microtasks; delay++) { + final (client, _) = clients[delay]; + final stopwatch = Stopwatch(); + final tornDown = Completer(); + client.inputStream.listen(null, onDone: () async { + for (var i = 0; i < delay; i++) { + await Future.value(); + } + stopwatch.start(); + tornDown.complete(teardown(client)); + }); + await expectLater( + client.reconnect(), throwsA(isA())) + .timeout(tunnelDeadline); + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 1)), + reason: 'teardown $delay microtasks after the old input ended'); + await tornDown.future.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + } + }); + }); + } + + test('reconnect() after cancel() connects again (TLS=$tls)', () async { + final server = await TunnelServer.start(tls: tls); + final hold = server.holdConnect = TunnelHold(); + final client = await server.createClient(); + await client.connect(); + final connecting = client.connectTo('localhost', 443); + final failed = + expectLater(connecting, throwsA(isA())); + await hold.reached.future.timeout(tunnelDeadline); + await client.cancel(); + await failed.timeout(tunnelDeadline); + // The abandoned handshake stays parked; only new connections proceed. + server.holdConnect = null; + await client.reconnect().timeout(tunnelDeadline); + expect(client.state, SocksSocketState.connected); + final peer = await server.nextPeer(); + await client.write('again'); + await peer.firstChunk.future.timeout(tunnelDeadline); + expect(peer.bytes.takeBytes(), 'again'.codeUnits); + }); + } + + test('a second reconnect() is rejected while one is in progress', () async { + final server = await TunnelServer.start(); + final (client, _) = await server.connect(); + final reconnecting = client.reconnect(); + await expectLater(client.reconnect(), throwsStateError); + await reconnecting.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.connected); + }); + + for (final MapEntry(key: name, value: teardown) in teardowns.entries) { + test('$name ends a TLS handshake at once', () async { + final server = await TunnelServer.start(tls: true); + // Part of the server's reply sits in the TLS filter, so the SDK ignores + // the destroyed transport and waits for the handshake deadline. + final hold = server.holdTls = TunnelHold(after: 64); + final client = await server.createClient( + handshakeTimeout: const Duration(seconds: 5)); + await client.connect(); + final connecting = client.connectTo('localhost', 443); + final failed = + expectLater(connecting, throwsA(isA())); + await hold.reached.future.timeout(tunnelDeadline); + final stopwatch = Stopwatch()..start(); + final tearingDown = teardown(client); + await failed.timeout(tunnelDeadline); + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 1))); + await tearingDown.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + hold.release.complete(); + await client.close().timeout(tunnelDeadline); + }); + } +} From 53eca8d0b242470bb16f9335f34fff40dd264c02 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 13:20:56 -0500 Subject: [PATCH 04/20] feat: add destroy() that fails writes it cuts short Destroying the underlying socket mid-write could report the write as sent: dart:io completes a pending flush on a destroyed Socket normally, so outputStream.addStream() and write() succeeded although nothing was delivered. SOCKSSocket.destroy() fails pending writes first, then tears the connection down without draining output, and _queueWrite() no longer trusts a flush that completes after the connection has failed. destroy() also ends a connect, TLS handshake or reconnect() in flight with SocksCancelledException, and a later close() completes normally even when destroy() failed a close that was already draining. It may be called from an upload source's onCancel: the sink clears its bound stream before cancelling it, so the re-entrant teardown completes the stream's future once. Fixes #2 --- CHANGELOG.md | 1 + README.md | 2 ++ lib/socks_socket.dart | 38 +++++++++++++++++++- test/destroy_test.dart | 68 ++++++++++++++++++++++++++++++++++++ test/teardown_race_test.dart | 53 ++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 test/destroy_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1299978..4dc4bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased +- Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. - Fail a handshake read or write at once when the socket reports its error synchronously, as macOS does for a reset peer, instead of waiting for the handshake deadline. `SocksConnection` shares the fix. - Document that `reconnect()` works after `cancel()`. diff --git a/README.md b/README.md index cce72b8..6cba86c 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ await socksSocket.write('{"jsonrpc":"2.0","method":"server.ping","id":1}', After `cancel()` or `close()` during connect, `reconnect()` opens a new connection once a target is known. `reconnect()` closes the current connection first, which ends `inputStream`; a `close()` or `destroy()` while it is in progress cancels it, including one made from that stream's `onDone`. Peer close is noticed, and `state` updated, only while `inputStream` has a listener. +`destroy()` aborts a connection without draining output; pending writes fail, and a connect, TLS handshake or `reconnect()` in flight ends with `SocksCancelledException`. Use `close()` to drain accepted output before closing; it ends a connect or `reconnect()` in flight the same way. + ## HttpClient Connections `SocksConnection.start` returns a cancellable `ConnectionTask` for `HttpClient.connectionFactory`; see `example/http/http_connection.dart`. Pass `tlsHost` for HTTPS. Requires Dart 3.5. diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 508ffb3..1818d46 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -132,6 +132,10 @@ class SOCKSSocket { late Socket _socksSocket; /// Getter for the underlying Socket that connects to the SOCKS5 proxy server. + /// + /// To abort the connection, call [destroy] rather than `socket.destroy()`: + /// dart:io completes a pending flush on a destroyed [Socket] normally, so + /// writes cut short that way would be reported as sent. Socket get socket => sslEnabled ? _secureSocksSocket : _socksSocket; /// A wrapper around the _socksSocket that enables SSL connections. @@ -796,7 +800,16 @@ class SOCKSSocket { try { socket.add(bytes); await socket.flush().timeout(_operationTimeout); + // A transport destroyed mid-flush can still complete the flush. + if (_writeFailure != null) { + Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!); + } } catch (e, stack) { + // A failure that already ended the connection, such as destroy(), + // is the cause; report it rather than the transport's symptom. + if (_writeFailure != null) { + Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!); + } final error = e is SocksException || e is TimeoutException ? e : SocksConnectionException(message: 'SOCKS5 write failed: $e'); @@ -831,6 +844,27 @@ class SOCKSSocket { Future _ensureClosed() => _closeFuture ??= _close(); + /// Tears the connection down at once, without draining output. + /// + /// Writes not yet delivered, through [write] or [outputStream], fail with a + /// [SocksConnectionException], and [inputStream] ends. A connect or + /// [reconnect] in flight fails with [SocksCancelledException]. A later + /// [close] completes normally. + void destroy() { + _closeRequested = true; + _signalCancel( + SocksCancelledException(message: 'SOCKS5 connection destroyed.'), + ); + _failWrites( + SocksConnectionException(message: 'SOCKS5 connection destroyed'), + StackTrace.current, + ); + _state = SocksSocketState.disconnected; + // A close already draining fails once its writes do; keep its outcome for + // the caller that started it, but let a later close() complete normally. + _closeFuture = (_closeFuture ?? _close()).catchError((Object _) {}); + } + Future _flushBeforeClose(Socket transport) async { final elapsed = Stopwatch()..start(); while (true) { @@ -1080,9 +1114,11 @@ class _SocketOutputSink implements StreamSink> { void _endStream([Object? error, StackTrace? stack]) { final completed = _streamDone; - _source?.cancel(); + final source = _source; _source = null; _streamDone = null; + // The source's onCancel may call destroy(), which ends up back here. + source?.cancel(); if (completed == null) return; if (error == null) { completed.complete(); diff --git a/test/destroy_test.dart b/test/destroy_test.dart new file mode 100644 index 0000000..c4694cb --- /dev/null +++ b/test/destroy_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:socks_socket/socks_socket.dart'; +import 'package:test/test.dart'; + +import 'helpers/tunnel_peer.dart'; + +final _destroyed = isA() + .having((e) => e.message, 'message', contains('destroyed')); + +void main() { + for (final tls in [false, true]) { + group('destroy() (TLS=$tls)', () { + test('fails a pending outputStream write', () async { + final (client, peer) = await connectTunnel(tls: tls); + // The peer stops reading, so the write stays in flight. + peer.input.pause(); + final uploading = client.outputStream + .addStream(Stream.value(List.filled(8 * 1024 * 1024, 1))); + final failed = expectLater(uploading, throwsA(_destroyed)); + await Future.delayed(const Duration(milliseconds: 200)); + client.destroy(); + await failed.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + await client.close().timeout(tunnelDeadline); + }); + + test('fails a pending write()', () async { + final (client, peer) = await connectTunnel(tls: tls); + peer.input.pause(); + final writing = client.write('A' * (8 * 1024 * 1024)); + final failed = expectLater(writing, throwsA(_destroyed)); + await Future.delayed(const Duration(milliseconds: 200)); + client.destroy(); + await failed.timeout(tunnelDeadline); + }); + + test('rejects later writes', () async { + final (client, _) = await connectTunnel(tls: tls); + client.destroy(); + await expectLater(client.write('A'), throwsStateError); + expect(() => client.outputStream.add([65]), throwsStateError); + await client.close().timeout(tunnelDeadline); + }); + + test('ends inputStream', () async { + final (client, _) = await connectTunnel(tls: tls); + final input = client.inputStream.drain(); + client.destroy(); + await input.timeout(tunnelDeadline); + }); + + test('may be called from the upload source\'s onCancel', () async { + final (client, peer) = await connectTunnel(tls: tls); + peer.input.pause(); + final source = StreamController>(onCancel: client.destroy); + source.add(List.filled(8 * 1024 * 1024, 1)); + final uploading = client.outputStream.addStream(source.stream); + final failed = + expectLater(uploading, throwsA(isA())); + await Future.delayed(const Duration(milliseconds: 200)); + client.destroy(); + await failed.timeout(tunnelDeadline); + await client.close().timeout(tunnelDeadline); + }); + }); + } +} diff --git a/test/teardown_race_test.dart b/test/teardown_race_test.dart index ce3ea7c..9f4836e 100644 --- a/test/teardown_race_test.dart +++ b/test/teardown_race_test.dart @@ -8,6 +8,7 @@ import 'helpers/tunnel_peer.dart'; /// close() and destroy() racing a reconnect, a TLS handshake, or each other. void main() { final teardowns = Function(SOCKSSocket)>{ + 'destroy()': (client) async => client.destroy(), 'close()': (client) => client.close(), }; @@ -134,6 +135,18 @@ void main() { await peer.firstChunk.future.timeout(tunnelDeadline); expect(peer.bytes.takeBytes(), 'again'.codeUnits); }); + + test('reconnect() after destroy() connects again (TLS=$tls)', () async { + final server = await TunnelServer.start(tls: tls); + final (client, _) = await server.connect(); + client.destroy(); + await client.reconnect().timeout(tunnelDeadline); + expect(client.state, SocksSocketState.connected); + final peer = await server.nextPeer(); + await client.write('again'); + await peer.firstChunk.future.timeout(tunnelDeadline); + expect(peer.bytes.takeBytes(), 'again'.codeUnits); + }); } test('a second reconnect() is rejected while one is in progress', () async { @@ -168,4 +181,44 @@ void main() { await client.close().timeout(tunnelDeadline); }); } + + for (final tls in [false, true]) { + group('close() after destroy() (TLS=$tls)', () { + final payload = List.filled(8 * 1024 * 1024, 1); + + test('completes when destroy() failed an explicit close in flight', + () async { + final (client, peer) = await connectTunnel(tls: tls); + peer.input.pause(); + final uploading = client.outputStream.addStream(Stream.value(payload)); + final uploadFailed = + expectLater(uploading, throwsA(isA())); + await Future.delayed(const Duration(milliseconds: 200)); + final closing = client.close(); + final closeFailed = + expectLater(closing, throwsA(isA())); + client.destroy(); + await Future.wait([uploadFailed, closeFailed]).timeout(tunnelDeadline); + await client.close().timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + }); + + test('completes when destroy() failed a peer-EOF close in flight', + () async { + final (client, peer) = await connectTunnel(tls: tls); + final eof = client.inputStream.drain(); + peer.input.pause(); + final uploading = client.outputStream.addStream(Stream.value(payload)); + final uploadFailed = + expectLater(uploading, throwsA(isA())); + await Future.delayed(const Duration(milliseconds: 200)); + await peer.socket.close(); + await eof.timeout(tunnelDeadline); + client.destroy(); + await uploadFailed.timeout(tunnelDeadline); + await client.close().timeout(tunnelDeadline); + expect(client.state, SocksSocketState.disconnected); + }); + }); + } } From 4dffbfb7da94e03d61435b2ebe59295b714bb1ec Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 13:21:04 -0500 Subject: [PATCH 05/20] feat: add closeOnPeerEof to keep writing after a peer half-close When the server half-closed the tunnel, SOCKSSocket closed itself, so every write started afterwards failed with "Output sink is closed", although the server was still reading. create(closeOnPeerEof: false) now ends only inputStream on peer EOF; the socket stays connected and keeps writing until close(). The default keeps the 1.4.0 behaviour, so callers who hit #3 must opt in. See #3 --- CHANGELOG.md | 1 + README.md | 2 ++ lib/socks_socket.dart | 20 ++++++++++++++++---- test/helpers/tunnel_peer.dart | 6 ++++++ test/peer_eof_test.dart | 19 +++++++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc4bc5..07a206d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Unreleased - Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. +- Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3). - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. - Fail a handshake read or write at once when the socket reports its error synchronously, as macOS does for a reset peer, instead of waiting for the handshake deadline. `SocksConnection` shares the fix. - Document that `reconnect()` works after `cancel()`. diff --git a/README.md b/README.md index 6cba86c..62f34b2 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ await socksSocket.write('{"jsonrpc":"2.0","method":"server.ping","id":1}', After `cancel()` or `close()` during connect, `reconnect()` opens a new connection once a target is known. `reconnect()` closes the current connection first, which ends `inputStream`; a `close()` or `destroy()` while it is in progress cancels it, including one made from that stream's `onDone`. Peer close is noticed, and `state` updated, only while `inputStream` has a listener. +By default the connection closes once the peer closes its side. Pass `closeOnPeerEof: false` to `create()` to keep writing after a peer half-close until you call `close()`. + `destroy()` aborts a connection without draining output; pending writes fail, and a connect, TLS handshake or `reconnect()` in flight ends with `SocksCancelledException`. Use `close()` to drain accepted output before closing; it ends a connect or `reconnect()` in flight the same way. ## HttpClient Connections diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 1818d46..ebe7325 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -226,7 +226,8 @@ class SOCKSSocket { SocksSocketState _state = SocksSocketState.disconnected; /// Peer close or failure is noticed only while [inputStream] has a listener; - /// until then a [write] to a dead peer may appear to succeed. + /// until then a [write] to a dead peer may appear to succeed. With + /// `closeOnPeerEof: false`, peer close leaves the state unchanged. SocksSocketState get state => _state; /// Target domain for [reconnect]. @@ -276,6 +277,8 @@ class SOCKSSocket { final bool? _requireIsolation; + final bool _closeOnPeerEof; + /// Private constructor. SOCKSSocket._( this.proxyHost, @@ -286,7 +289,8 @@ class SOCKSSocket { this._operationTimeout, this._allowBadCertificates, this._securityContext, - this._requireIsolation); + this._requireIsolation, + this._closeOnPeerEof); /// Provides a stream of data as List. /// @@ -319,6 +323,10 @@ class SOCKSSocket { } /// Creates a SOCKS5 socket to the specified [proxyHost] and [proxyPort]. + /// + /// By default the connection closes once the peer closes its side. With + /// [closeOnPeerEof] false, peer EOF only ends [inputStream]: the socket stays + /// [SocksSocketState.connected] and keeps writing until [close]. static Future create({ required String proxyHost, required int proxyPort, @@ -329,6 +337,7 @@ class SOCKSSocket { bool allowBadCertificates = false, SecurityContext? securityContext, bool? requireIsolation, + bool closeOnPeerEof = true, }) async { _checkIsolationToken(isolationToken); if (requireIsolation == true && isolationToken == null) { @@ -348,7 +357,8 @@ class SOCKSSocket { operationTimeout, allowBadCertificates, securityContext, - requireIsolation); + requireIsolation, + closeOnPeerEof); // Initialize the SOCKS socket. await instance._init(); @@ -368,7 +378,8 @@ class SOCKSSocket { _operationTimeout = const Duration(seconds: 30), _allowBadCertificates = false, _securityContext = null, - _requireIsolation = null { + _requireIsolation = null, + _closeOnPeerEof = true { _init(); } @@ -451,6 +462,7 @@ class SOCKSSocket { /// The peer stopped sending; writes already accepted are still delivered. void _peerClosed() { _peerReadClosed = true; + if (!_closeOnPeerEof) return; _state = SocksSocketState.disconnected; _ensureClosed().ignore(); } diff --git a/test/helpers/tunnel_peer.dart b/test/helpers/tunnel_peer.dart index a2e6a5c..f46fec6 100644 --- a/test/helpers/tunnel_peer.dart +++ b/test/helpers/tunnel_peer.dart @@ -184,6 +184,7 @@ class TunnelServer { Future createClient({ Duration handshakeTimeout = const Duration(seconds: 30), Duration operationTimeout = tunnelDeadline, + bool closeOnPeerEof = true, }) async { final client = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, @@ -192,6 +193,7 @@ class TunnelServer { securityContext: certificates?.clientContext(), handshakeTimeout: handshakeTimeout, operationTimeout: operationTimeout, + closeOnPeerEof: closeOnPeerEof, ); addTearDown(() => client.close().catchError((_) {})); return client; @@ -201,10 +203,12 @@ class TunnelServer { Future<(SOCKSSocket, TunnelPeer)> connect({ Duration handshakeTimeout = const Duration(seconds: 30), Duration operationTimeout = tunnelDeadline, + bool closeOnPeerEof = true, }) async { final client = await createClient( handshakeTimeout: handshakeTimeout, operationTimeout: operationTimeout, + closeOnPeerEof: closeOnPeerEof, ); await client.connect(); await client.connectTo('localhost', 443); @@ -230,9 +234,11 @@ class TunnelServer { Future<(SOCKSSocket, TunnelPeer)> connectTunnel({ bool tls = false, Duration operationTimeout = tunnelDeadline, + bool closeOnPeerEof = true, }) async { final server = await TunnelServer.start(tls: tls); return server.connect( operationTimeout: operationTimeout, + closeOnPeerEof: closeOnPeerEof, ); } diff --git a/test/peer_eof_test.dart b/test/peer_eof_test.dart index 6da9b42..fea732f 100644 --- a/test/peer_eof_test.dart +++ b/test/peer_eof_test.dart @@ -82,6 +82,25 @@ void main() { await peer.done.future.timeout(_deadline); expect(peer.bytes.takeBytes(), [65, 66]); }); + test('closeOnPeerEof: false keeps writes open after peer EOF (TLS=$tls)', + () async { + final (client, peer) = + await connectTunnel(tls: tls, closeOnPeerEof: false); + final eof = client.inputStream.drain(); + await peer.socket.close(); + await eof.timeout(_deadline); + + expect(client.state, ConnectionState.connected); + await client.write('A').timeout(_deadline); + await client.outputStream + .addStream(Stream.value([66])) + .timeout(_deadline); + client.outputStream.add([67]); + await client.close().timeout(_deadline); + await peer.done.future.timeout(_deadline); + expect(peer.bytes.takeBytes(), [65, 66, 67]); + expect(client.state, ConnectionState.disconnected); + }); } test('an output sink first used after peer EOF rejects new uploads', From 7823d71b5329ae55d8a28a34bc7a3d066970c9e7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 13:21:12 -0500 Subject: [PATCH 06/20] feat!: keep the SOCKSSocket transport private Release as 2.0.0 and document migration to managed I/O, teardown, and TLS. Remove close-time flush retries for operations that callers can no longer start outside the wrapper. Tests use the wrapper APIs instead of the removed getter. BREAKING CHANGE: remove SOCKSSocket.socket; use the wrapper APIs and built-in TLS instead. --- CHANGELOG.md | 4 +- README.md | 19 ++++ lib/socks_socket.dart | 42 +------ pubspec.yaml | 2 +- test/connection_lifecycle_test.dart | 4 +- test/peer_eof_test.dart | 107 +++++++----------- ..._test.dart => tls_configuration_test.dart} | 14 +-- 7 files changed, 76 insertions(+), 116 deletions(-) rename test/{manual_tls_test.dart => tls_configuration_test.dart} (62%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a206d..89508a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ -## Unreleased +## 2.0.0 +- **Breaking:** Remove `SOCKSSocket.socket` so transport operations cannot bypass the wrapper's write queue and lifecycle tracking. Use `destroy()`, `close()`, `write()`, `outputStream`, and `inputStream` instead. +- **Breaking:** Caller-managed TLS through `SecureSocket.secure(socks.socket)` is no longer supported. Use `SOCKSSocket.create(sslEnabled: true, securityContext: ...)` to negotiate TLS during `connectTo()`. - Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. - Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3). - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. diff --git a/README.md b/README.md index 62f34b2..0f16c6a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,25 @@ By default the connection closes once the peer closes its side. Pass `closeOnPee `destroy()` aborts a connection without draining output; pending writes fail, and a connect, TLS handshake or `reconnect()` in flight ends with `SocksCancelledException`. Use `close()` to drain accepted output before closing; it ends a connect or `reconnect()` in flight the same way. +## Migrating to 2.0.0 + +`SOCKSSocket.socket` has been removed. All transport operations now go through the wrapper so writes and teardown share the same lifecycle tracking. + +| Previous operation | Replacement | +| --- | --- | +| `socks.socket.destroy()` | `socks.destroy()` | +| `socks.socket.close()` | `await socks.close()` | +| `socks.socket.add(bytes)` | `socks.outputStream.add(bytes)` | +| `socks.socket.addStream(source)` | `await socks.outputStream.addStream(source)` | +| Reading the underlying socket | `socks.inputStream` or `socks.listen(...)` | +| `SecureSocket.secure(socks.socket, ...)` | Set `sslEnabled: true` and, if needed, `securityContext` on `SOCKSSocket.create(...)` | + +For an awaited binary write, use `await socks.outputStream.addStream(Stream.value(bytes))`. The output sink accepts one stream at a time. For text, use `await socks.write(text)`. To drain and finish the connection, use `await socks.close()`. + +Built-in TLS starts during `connectTo()`, after the SOCKS handshake. Upgrading an established plaintext application session to TLS is not exposed by `SOCKSSocket`. + +`SocksConnection.start` still returns a `ConnectionTask` for `HttpClient.connectionFactory`; that separate API is unchanged. + ## HttpClient Connections `SocksConnection.start` returns a cancellable `ConnectionTask` for `HttpClient.connectionFactory`; see `example/http/http_connection.dart`. Pass `tlsHost` for HTTPS. Requires Dart 3.5. diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index ebe7325..884a6ce 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -131,12 +131,7 @@ class SOCKSSocket { /// The underlying Socket that connects to the SOCKS5 proxy server. late Socket _socksSocket; - /// Getter for the underlying Socket that connects to the SOCKS5 proxy server. - /// - /// To abort the connection, call [destroy] rather than `socket.destroy()`: - /// dart:io completes a pending flush on a destroyed [Socket] normally, so - /// writes cut short that way would be reported as sent. - Socket get socket => sslEnabled ? _secureSocksSocket : _socksSocket; + Socket get _socket => sslEnabled ? _secureSocksSocket : _socksSocket; /// A wrapper around the _socksSocket that enables SSL connections. late Socket _secureSocksSocket; @@ -402,7 +397,6 @@ class SOCKSSocket { _channel = RawChannel(raw); _socksSocket = _channel!.socket(); } else { - // Keep a native Socket so callers can use SecureSocket.secure(socket). _socksSocket = await _connectProxy(Socket.startConnect); _nativeSocketOpen = true; _nativeSocketNeedsDestroy = true; @@ -810,8 +804,8 @@ class SOCKSSocket { Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!); } try { - socket.add(bytes); - await socket.flush().timeout(_operationTimeout); + _socket.add(bytes); + await _socket.flush().timeout(_operationTimeout); // A transport destroyed mid-flush can still complete the flush. if (_writeFailure != null) { Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!); @@ -877,32 +871,6 @@ class SOCKSSocket { _closeFuture = (_closeFuture ?? _close()).catchError((Object _) {}); } - Future _flushBeforeClose(Socket transport) async { - final elapsed = Stopwatch()..start(); - while (true) { - final remaining = _operationTimeout - elapsed.elapsed; - if (remaining <= Duration.zero) { - throw TimeoutException( - 'SOCKS5 output did not drain before close', _operationTimeout); - } - final Future flushing; - try { - flushing = transport.flush(); - } on StateError { - // The public native socket may have an active flush/addStream outside - // _writeTail. IOSink rejects another flush synchronously while bound, - // and exposes no future for that operation. Keep its transport alive - // and retry within one deadline; wrapping it would break manual TLS. - const interval = Duration(milliseconds: 10); - await Future.delayed(remaining < interval ? remaining : interval); - continue; - } - // Async failures belong to the flush itself and must not be retried. - await flushing.timeout(remaining); - return; - } - } - /// Ends a connect in flight. The TLS handshake cannot notice a destroyed /// transport on its own, so it waits for this or for its deadline. void _signalCancel(SocksCancelledException error) { @@ -923,7 +891,9 @@ class SOCKSSocket { await _writeTail; final open = _nativeSocketOpen || (channel != null && !channel.isClosed); if (open && _writeFailure == null) { - await _flushBeforeClose(upgraded ? _secureSocksSocket : _socksSocket); + await (upgraded ? _secureSocksSocket : _socksSocket) + .flush() + .timeout(_operationTimeout); flushed = true; } } catch (error, stack) { diff --git a/pubspec.yaml b/pubspec.yaml index 28044e2..35b4b9f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: socks_socket description: SOCKS version 5 sockets for Dart and Flutter, *eg.* ElectrumX and/or Fulcrum over Tor via socket(s). -version: 1.4.0 +version: 2.0.0 repository: https://github.com/cypherstack/socks_socket environment: diff --git a/test/connection_lifecycle_test.dart b/test/connection_lifecycle_test.dart index af5da1c..4a351ff 100644 --- a/test/connection_lifecycle_test.dart +++ b/test/connection_lifecycle_test.dart @@ -82,7 +82,7 @@ void main() { }); test('peer EOF still delivers accepted writes', () async { - const size = 1024 * 1024; + const size = 8 * 1024 * 1024; final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); addTearDown(server.close); final received = Completer(); @@ -119,8 +119,6 @@ void main() { addTearDown(() => uploader.close().catchError((_) {})); await uploader.connect(); await uploader.connectTo('localhost', 80); - // Linux SO_SNDBUF. - uploader.socket.setRawOption(RawSocketOption.fromInt(1, 7, 65536)); final input = uploader.inputStream.drain(); await uploader.write('A' * size); await input; diff --git a/test/peer_eof_test.dart b/test/peer_eof_test.dart index fea732f..c6a795f 100644 --- a/test/peer_eof_test.dart +++ b/test/peer_eof_test.dart @@ -9,52 +9,30 @@ import 'helpers/tunnel_peer.dart'; const _deadline = tunnelDeadline; -// SOL_SOCKET, SO_SNDBUF and SO_LINGER differ between Linux and macOS. +// SOL_SOCKET and SO_LINGER differ between Linux and macOS. final _solSocket = Platform.isMacOS ? 0xffff : 1; -final _soSndbuf = Platform.isMacOS ? 0x1001 : 7; final _soLinger = Platform.isMacOS ? 0x80 : 13; void main() { - test('peer EOF drains a pending underlying socket flush', () async { - final (client, peer) = await connectTunnel(); - final eof = client.inputStream.drain(); - // Linux SO_SNDBUF: force the upload to remain pending while reads pause. - client.socket.setRawOption(RawSocketOption.fromInt(1, 7, 4096)); - peer.input.pause(); - final payload = List.generate(512 * 1024, (i) => i % 251); - client.socket.add(payload); - var flushed = false; - final flushing = client.socket.flush().then((_) => flushed = true); - flushing.ignore(); - await Future.delayed(const Duration(milliseconds: 20)); - expect(flushed, isFalse, reason: 'The regression needs a pending flush'); - await peer.socket.close(); - await eof.timeout(_deadline); - peer.input.resume(); - await flushing.timeout(_deadline); - await client.close().timeout(_deadline); - await peer.done.future.timeout(_deadline); - expect(peer.bytes.takeBytes(), payload); - }, testOn: 'linux'); - for (final tls in [false, true]) { - test('peer EOF drains underlying socket.addStream (TLS=$tls)', () async { + test('peer EOF drains a pending write (TLS=$tls)', () async { final (client, peer) = await connectTunnel(tls: tls); final eof = client.inputStream.drain(); - final source = StreamController>(); - addTearDown(source.close); - final uploading = client.socket.addStream(source.stream); - uploading.ignore(); - source.add([65]); - await peer.firstChunk.future.timeout(_deadline); + peer.input.pause(); + // Exceed the transport buffers without accessing the client's socket. + final payload = 'A' * (8 * 1024 * 1024); + var flushed = false; + final writing = client.write(payload).then((_) => flushed = true); + writing.ignore(); + await Future.delayed(const Duration(milliseconds: 200)); + expect(flushed, isFalse, reason: 'The regression needs a pending write'); await peer.socket.close(); await eof.timeout(_deadline); - source.add([66]); - await source.close(); - await uploading.timeout(_deadline); + peer.input.resume(); + await writing.timeout(_deadline); await client.close().timeout(_deadline); await peer.done.future.timeout(_deadline); - expect(peer.bytes.takeBytes(), [65, 66]); + expect(peer.bytes.takeBytes(), payload.codeUnits); }); test('peer EOF drains an accepted outputStream (TLS=$tls)', () async { @@ -117,24 +95,21 @@ void main() { expect(peer.bytes.length, 0); }); - test('reset after peer EOF cancels a paused underlying upload source', - () async { + test('reset after peer EOF cancels a paused outputStream source', () async { final (client, peer) = await connectTunnel(); final eof = client.inputStream.drain(); final source = StreamController>(); addTearDown(() async { - client.socket.destroy(); + client.destroy(); await source.close(); }); - // Shrink SO_SNDBUF to keep the source paused in an in-flight write. - client.socket - .setRawOption(RawSocketOption.fromInt(_solSocket, _soSndbuf, 4096)); - final uploading = client.socket.addStream(source.stream); - final failed = expectLater(uploading, throwsA(isA())); + final uploading = client.outputStream.addStream(source.stream); + final failed = + expectLater(uploading, throwsA(isA())); source.add([65]); await peer.firstChunk.future.timeout(_deadline); peer.input.pause(); - source.add(List.filled(512 * 1024, 66)); + source.add(List.filled(8 * 1024 * 1024, 66)); await Future.delayed(const Duration(milliseconds: 20)); expect(source.isPaused, isTrue); await peer.socket.close(); @@ -144,31 +119,29 @@ void main() { Uint8List.view(Int32List.fromList([1, 0]).buffer))); peer.socket.destroy(); await failed.timeout(_deadline); - await client.close().timeout(_deadline); + await expectLater(client.close().timeout(_deadline), + throwsA(isA())); expect(source.hasListener, isFalse); await source.close().timeout(_deadline); }, testOn: 'linux || mac-os'); - for (final direct in [false, true]) { - test('close times out when an upload never ends (direct=$direct)', - () async { - final (client, peer) = await connectTunnel( - operationTimeout: const Duration(milliseconds: 100), - ); - final eof = client.inputStream.drain(); - final source = StreamController>(); - addTearDown(source.close); - final output = direct ? client.socket : client.outputStream; - output.addStream(source.stream).ignore(); - source.add([65]); - await peer.firstChunk.future.timeout(_deadline); - await peer.socket.close(); - await eof.timeout(_deadline); - await expectLater( - client.close().timeout(_deadline), throwsA(isA())); - await peer.done.future.timeout(_deadline); - expect(client.state, ConnectionState.disconnected); - expect(source.hasListener, isFalse); - }); - } + test('close times out when an outputStream upload never ends', () async { + final (client, peer) = await connectTunnel( + operationTimeout: const Duration(milliseconds: 100), + ); + final eof = client.inputStream.drain(); + final source = StreamController>(); + addTearDown(source.close); + final output = client.outputStream; + output.addStream(source.stream).ignore(); + source.add([65]); + await peer.firstChunk.future.timeout(_deadline); + await peer.socket.close(); + await eof.timeout(_deadline); + await expectLater( + client.close().timeout(_deadline), throwsA(isA())); + await peer.done.future.timeout(_deadline); + expect(client.state, ConnectionState.disconnected); + expect(source.hasListener, isFalse); + }); } diff --git a/test/manual_tls_test.dart b/test/tls_configuration_test.dart similarity index 62% rename from test/manual_tls_test.dart rename to test/tls_configuration_test.dart index e05b433..a64ef1c 100644 --- a/test/manual_tls_test.dart +++ b/test/tls_configuration_test.dart @@ -8,7 +8,7 @@ import 'helpers/mock_socks_server.dart'; import 'helpers/test_certificates.dart'; void main() { - test('legacy plain socket supports a caller-managed TLS upgrade', () async { + test('SOCKSSocket manages TLS with a custom security context', () async { final certificates = TestCertificates.generate(); final proxy = MockSocksServer() ..sslEnabled = true @@ -18,16 +18,14 @@ void main() { final socket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, proxyPort: proxy.port, + sslEnabled: true, + securityContext: certificates.clientContext(), ); addTearDown(socket.close); await socket.connect(); await socket.connectTo('localhost', 443); - final secured = await SecureSocket.secure(socket.socket, - host: 'localhost', context: certificates.clientContext()); - addTearDown(secured.destroy); - final reply = secured.expand((b) => b).take(10).toList(); - secured.add(utf8.encode('manual TLS')); - await secured.flush(); - expect(utf8.decode(await reply), 'manual TLS'); + final reply = socket.inputStream.expand((b) => b).take(10).toList(); + await socket.write('custom TLS'); + expect(utf8.decode(await reply), 'custom TLS'); }); } From 0be8862362deb17913918f92550dae2078467764 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 14:04:15 -0500 Subject: [PATCH 07/20] refactor: drive SOCKSSocket from one lifecycle phase SOCKSSocket tracked its life in a dozen flags that each held part of the same answer: _state, _closing, _cancelled, _greetingStarted, _greetingComplete, _requestStarted, _peerReadClosed, _sslUpgraded, _nativeSocketOpen, _nativeSocketNeedsDestroy, _applicationInputPaused, plus a generation counter to ignore stale callbacks. Every teardown race found in review was two of them disagreeing. Replace them with a private phase enum (idle, greeting, greeted, requesting, connected, closing, cancelled, closed, failed) and derive the public state from it, so the API is unchanged: idle, closing, cancelled, closed -> disconnected greeting, greeted, requesting -> connecting connected -> connected failed -> error Two simplifications make that possible. The plain connection now runs over the same raw channel as TLS and SocksConnection, which removes the native-socket bookkeeping, the second response controller and channel, and the generation counter (callbacks compare the transport or sink they belong to instead). Handshake replies are read straight from the channel in exact sizes, as SocksConnection already does, which removes the handshake controller, the reply accumulator and the buffer that preserved application bytes after the CONNECT reply; those bytes now simply stay in the socket until inputStream starts. A greeting or authentication reply with trailing bytes still fails the handshake. All teardown paths go through two primitives: _abandon destroys the transport and ends inputStream, _fail also records the failure for the output sink. A closed socket stays closed; failures its own teardown provokes do not move it to error. Observable differences: state is disconnected as soon as close() starts draining rather than once it completes, and transport errors read the same on plain and TLS connections. The class is 290 lines shorter and all 202 tests pass unchanged. --- CHANGELOG.md | 3 + lib/socks_socket.dart | 1014 +++++++++++++++-------------------------- 2 files changed, 368 insertions(+), 649 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89508a7..37190b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. - Fail a handshake read or write at once when the socket reports its error synchronously, as macOS does for a reset peer, instead of waiting for the handshake deadline. `SocksConnection` shares the fix. - Document that `reconnect()` works after `cancel()`. +- Drive `SOCKSSocket` from one private lifecycle phase instead of a dozen flags. No API change. `state` now reads `disconnected` as soon as `close()` starts draining, not only once it completes. +- Carry the plain connection over the same raw channel as TLS and `SocksConnection`, so transport errors read the same on both transports. +- Read handshake replies straight from the channel, as `SocksConnection` does. A greeting or authentication reply with trailing bytes still fails the handshake, and a stray byte before the CONNECT reply now fails it too instead of being dropped. Handshake failure messages come from the shared protocol code. ## 1.4.0 diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 884a6ce..9dc577f 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -74,49 +74,47 @@ enum SocksSocketState { typedef ConnectionState = SocksSocketState; +/// Where a [SOCKSSocket] is in its life. [SOCKSSocket.state] is derived from it. +enum _Phase { + /// A TCP connection to the proxy is open; [SOCKSSocket.connect] may run. + idle, + + /// [SOCKSSocket.connect] is negotiating the greeting. + greeting, + + /// The greeting succeeded; [SOCKSSocket.connectTo] may run. + greeted, + + /// [SOCKSSocket.connectTo] is requesting the target, then negotiating TLS. + requesting, + connected, + + /// [SOCKSSocket.close] is draining accepted output. + closing, + + /// [SOCKSSocket.cancel] ended a connect; [SOCKSSocket.reconnect] may run. + cancelled, + + /// Closed or destroyed; [SOCKSSocket.reconnect] may run. + closed, + + /// The handshake or the transport failed; [SOCKSSocket.reconnect] may run. + failed, +} + /// A SOCKS5 socket. /// -/// A Dart 3 Socket wrapper that implements the SOCKS5 protocol. Now with SSL! -/// -/// Properties: -/// - [proxyHost]: The host of the SOCKS5 proxy server. -/// - [proxyPort]: The port of the SOCKS5 proxy server. -/// - [_socksSocket]: The underlying Socket that connects to the SOCKS5 proxy -/// server. -/// - [_responseController]: A StreamController that listens to the -/// [_socksSocket] and broadcasts the response. -/// -/// Methods: -/// - connect: Connects to the SOCKS5 proxy server. -/// - connectTo: Connects to the specified [domain] and [port] through the -/// SOCKS5 proxy server. -/// - write: Converts [object] to a String by invoking [Object.toString] and -/// sends the encoding of the result to the socket. -/// - sendServerFeaturesCommand: Sends the server.features command to the -/// proxy server. -/// - close: Closes the connection to the Tor proxy. -/// /// Usage: /// ```dart -/// // Instantiate a socks socket at localhost and on the port selected by the -/// // tor service. -/// var socksSocket = await SOCKSSocket.create( -/// proxyHost: InternetAddress.loopbackIPv4.address, -/// proxyPort: tor.port, -/// // sslEnabled: true, // For SSL connections. -/// ); -/// -/// // Connect to the socks instantiated above. -/// await socksSocket.connect(); -/// -/// // Connect to bitcoincash.stackwallet.com on port 50001 via socks socket. -/// await socksSocket.connectTo( -/// 'bitcoincash.stackwallet.com', 50001); -/// -/// // Send a server features command to the connected socket, see method for -/// // more specific usage example.. -/// await socksSocket.sendServerFeaturesCommand(); -/// await socksSocket.close(); +/// final socks = await SOCKSSocket.create( +/// proxyHost: InternetAddress.loopbackIPv4.address, +/// proxyPort: tor.port, +/// // sslEnabled: true, // Negotiate TLS with the target after CONNECT. +/// ); +/// await socks.connect(); +/// await socks.connectTo('bitcoincash.stackwallet.com', 50001); +/// await socks.sendServerFeaturesCommand(); +/// await socks.close(); /// ``` /// /// See also: @@ -128,153 +126,63 @@ class SOCKSSocket { /// The port of the SOCKS5 proxy server. final int proxyPort; - /// The underlying Socket that connects to the SOCKS5 proxy server. - late Socket _socksSocket; - - Socket get _socket => sslEnabled ? _secureSocksSocket : _socksSocket; - - /// A wrapper around the _socksSocket that enables SSL connections. - late Socket _secureSocksSocket; - - bool _nativeSocketOpen = false; - bool _nativeSocketNeedsDestroy = false; - - RawChannel? _channel; - - RawChannel? _secureChannel; + /// Whether [connectTo] negotiates TLS with the target. + final bool sslEnabled; - /// A StreamController that listens to the _socksSocket and broadcasts. - late StreamController> _responseController = - _newResponseController(); + /// Timeout for the TCP connect, each handshake step, and the TLS upgrade. + final Duration _handshakeTimeout; - /// Bumped on teardown so a replaced connection's callbacks are ignored. - int _generation = 0; + /// Timeout for flushing a write, and for draining output in [close]. + final Duration _operationTimeout; - StreamController> _newResponseController() { - final generation = _generation; - return StreamController>.broadcast( - onListen: () { - if (generation == _generation) _resumeApplicationInput(); - }, - onCancel: () { - if (generation == _generation) _pauseApplicationInput(); - }, - ); - } + /// Accept bad certificates (testing only). + final bool _allowBadCertificates; - List? _pendingApplicationData; - bool _applicationInputPaused = false; + final SecurityContext? _securityContext; - /// Broadcast: each handshake step listens and cancels in turn. - StreamController>? _handshakeResponses; + final bool? _requireIsolation; - bool get _handshaking => _state != SocksSocketState.connected; + final bool _closeOnPeerEof; - void _resumeApplicationInput() { - if (_state != SocksSocketState.connected || - !responseController.hasListener) { - return; - } - final pending = _pendingApplicationData; - _pendingApplicationData = null; - if (pending != null && pending.isNotEmpty) { - _responseController.add(pending); - } - if (_applicationInputPaused) { - _applicationInputPaused = false; - _subscription?.resume(); - } - } + /// Tor circuit isolation token (RFC 1929 username/password auth). + String? _isolationToken; - void _pauseApplicationInput() { - if (_state == SocksSocketState.connected) _pauseInput(); - } + /// Target of the last [connectTo], for [reconnect]. + String? _targetDomain; + int? _targetPort; - void _pauseInput() { - if (!_applicationInputPaused) { - _applicationInputPaused = true; - _subscription?.pause(); - } - } + _Phase _phase = _Phase.idle; - /// A StreamController that listens to the _secureSocksSocket and broadcasts. - late StreamController> _secureResponseController = - _newResponseController(); + /// The connection to the proxy: a raw TCP channel until [connectTo] has + /// negotiated TLS, then the TLS channel. + RawChannel? _channel; - /// Getter for the StreamController that listens to the _socksSocket and - /// broadcasts, or the _secureSocksSocket and broadcasts if SSL is enabled. - StreamController> get responseController => - sslEnabled ? _secureResponseController : _responseController; + /// [_channel] as a [Socket], once connected. + Socket? _transport; - /// A StreamSubscription that listens to the _socksSocket or the - /// _secureSocksSocket if SSL is enabled. + /// Reads [_transport] into [_input]; paused while [_input] has no listener. StreamSubscription>? _subscription; - /// Getter for the StreamSubscription that listens to the _socksSocket or the - /// _secureSocksSocket if SSL is enabled. - StreamSubscription>? get subscription => _subscription; - - /// Is SSL enabled? - final bool sslEnabled; - - /// Current connection state. - SocksSocketState _state = SocksSocketState.disconnected; - - /// Peer close or failure is noticed only while [inputStream] has a listener; - /// until then a [write] to a dead peer may appear to succeed. With - /// `closeOnPeerEof: false`, peer close leaves the state unchanged. - SocksSocketState get state => _state; - - /// Target domain for [reconnect]. - String? _targetDomain; - - /// Target port for [reconnect]. - int? _targetPort; - - /// Timeout for SOCKS5 handshake and TCP connect. - final Duration _handshakeTimeout; + /// The proxy TCP connect in flight, so [close] and [destroy] can end it. + ConnectionTask? _connectTask; - /// Timeout for socket flush after [write]. - final Duration _operationTimeout; + /// Ends the handshake in flight; see [_signalCancel]. + Completer? _cancel; - static const int _maxHandshakeBuffer = 1024; + late StreamController> _input = _newInput(); _SocketOutputSink? _outputSink; Future _writeTail = Future.value(); Object? _writeFailure; StackTrace? _writeFailureStack; + Future? _closeFuture; - bool _closing = false; /// Set by [close] and [destroy]; a [reconnect] in flight stops at its next /// step instead of opening a connection the caller has already given up. bool _closeRequested = false; - bool _peerReadClosed = false; - - /// Accept bad certificates (testing only). - final bool _allowBadCertificates; - - final SecurityContext? _securityContext; - - /// Cancel signal for in-flight operations. - Completer? _cancelCompleter; - - /// The proxy TCP connect in flight, so [close] and [destroy] can end it. - ConnectionTask? _connectTask; - bool _cancelled = false; - bool _greetingStarted = false; - bool _greetingComplete = false; - bool _requestStarted = false; bool _reconnecting = false; - /// Tor circuit isolation token (RFC 1929 username/password auth). - String? _isolationToken; - - final bool? _requireIsolation; - - final bool _closeOnPeerEof; - - /// Private constructor. SOCKSSocket._( this.proxyHost, this.proxyPort, @@ -287,36 +195,6 @@ class SOCKSSocket { this._requireIsolation, this._closeOnPeerEof); - /// Provides a stream of data as List. - /// - /// Reads pause while there is no listener; earlier data is kept. - Stream> get inputStream => sslEnabled - ? _secureResponseController.stream - : _responseController.stream; - - StreamSink> get outputStream => _outputSink ??= _newOutputSink(); - - _SocketOutputSink _newOutputSink() { - final generation = _generation; - final sink = _SocketOutputSink(_queueSinkWrite, (error, stack) { - if (generation == _generation) _failWrites(error, stack); - }); - if (_writeFailure != null) { - sink._stop(_writeFailure!, _writeFailureStack!); - } else if (_closing) { - sink.close().ignore(); - } - return sink; - } - - Future _queueSinkWrite(List data) { - if (!_canQueueWrite(allowClosing: true)) { - throw StateError( - 'Cannot write: socket is not connected (state: $_state)'); - } - return _queueWrite(data, allowClosing: true); - } - /// Creates a SOCKS5 socket to the specified [proxyHost] and [proxyPort]. /// /// By default the connection closes once the peer closes its side. With @@ -341,9 +219,7 @@ class SOCKSSocket { 'requireIsolation', ); } - - // Create a SOCKS socket instance. - var instance = SOCKSSocket._( + final instance = SOCKSSocket._( proxyHost, proxyPort, sslEnabled, @@ -354,15 +230,11 @@ class SOCKSSocket { securityContext, requireIsolation, closeOnPeerEof); - - // Initialize the SOCKS socket. await instance._init(); - - // Return the SOCKS socket instance. return instance; } - /// Deprecated. Does not await _init(); use [SOCKSSocket.create]. + /// Deprecated. Does not await the proxy connection; use [SOCKSSocket.create]. @Deprecated('Use SOCKSSocket.create() instead') SOCKSSocket({ required this.proxyHost, @@ -378,72 +250,92 @@ class SOCKSSocket { _init(); } - /// Initializes the SOCKS socket. + static void _checkIsolationToken(String? token) { + if (token != null) encodeSocksCredentials(token, token); + } + + /// Current connection state. /// - /// This method is a private method that is called by the constructor. + /// Peer close or failure is noticed only while [inputStream] has a listener; + /// until then a [write] to a dead peer may appear to succeed. With + /// `closeOnPeerEof: false`, peer close leaves the state unchanged. + SocksSocketState get state => switch (_phase) { + _Phase.idle || + _Phase.closing || + _Phase.cancelled || + _Phase.closed => + SocksSocketState.disconnected, + _Phase.greeting || + _Phase.greeted || + _Phase.requesting => + SocksSocketState.connecting, + _Phase.connected => SocksSocketState.connected, + _Phase.failed => SocksSocketState.error, + }; + + bool get _connecting => + _phase == _Phase.greeting || + _phase == _Phase.greeted || + _phase == _Phase.requesting; + + /// Whether [close], [cancel] or [destroy] has given the connection up. + bool get _givenUp => + _phase == _Phase.closing || + _phase == _Phase.cancelled || + _phase == _Phase.closed; + + /// Data from the target, as List. /// - /// Returns: - /// A Future that resolves to void. - Future _init() async { - // Connect to the SOCKS proxy server. - _applicationInputPaused = false; - _cancelled = false; - _greetingStarted = false; - _greetingComplete = false; - _requestStarted = false; - _peerReadClosed = false; - if (sslEnabled) { - final raw = await _connectProxy(RawSocket.startConnect); - _channel = RawChannel(raw); - _socksSocket = _channel!.socket(); - } else { - _socksSocket = await _connectProxy(Socket.startConnect); - _nativeSocketOpen = true; - _nativeSocketNeedsDestroy = true; - _socksSocket.done.then((_) { - _nativeSocketOpen = false; - }, onError: (Object _) { - _nativeSocketOpen = false; - }); - } - _secureChannel = null; - final responses = _responseController; - final handshake = _handshakeResponses = StreamController.broadcast(); - - // Listen to the socket. - _subscription = _socksSocket.listen( - (data) { - (_handshaking ? handshake : _responseController).add(data); - }, - onError: (Object e, StackTrace stack) { - final error = e is SocksException - ? e - : SocksConnectionException( - message: 'SOCKS5 connection error: $e', - ); - if (_handshaking && !handshake.isClosed) handshake.addError(error); - if (!responses.isClosed) responses.addError(error); - if (_state == SocksSocketState.connected) _failWrites(error, stack); + /// Reads pause while there is no listener; earlier data is kept. + Stream> get inputStream => _input.stream; + + /// The controller behind [inputStream]. + StreamController> get responseController => _input; + + /// The subscription that feeds [inputStream], once connected. + StreamSubscription>? get subscription => _subscription; + + StreamSink> get outputStream => _outputSink ??= _newOutputSink(); + + StreamController> _newInput() { + late final StreamController> input; + input = StreamController>.broadcast( + onListen: () { + if (identical(input, _input)) _subscription?.resume(); }, - onDone: () { - // Close the response controller when the socket is closed. - if (!handshake.isClosed) handshake.close(); - if (!_sslUpgraded) { - if (_state == SocksSocketState.connected) _peerClosed(); - if (!responses.isClosed) responses.close(); - } + onCancel: () { + if (identical(input, _input)) _subscription?.pause(); }, ); + return input; } - Future _connectProxy( - Future> Function(String host, int port) start) async { - final task = _connectTask = await start(proxyHost, proxyPort); - // A close() or destroy() while the task was being created found - // nothing to cancel yet. + _SocketOutputSink _newOutputSink() { + late final _SocketOutputSink sink; + sink = _SocketOutputSink(_queueSinkWrite, (error, stack) { + if (identical(sink, _outputSink)) _fail(error, stack, _Phase.failed); + }); + if (_writeFailure != null) { + sink._stop(_writeFailure!, _writeFailureStack!); + } else if (_phase == _Phase.closing || _phase == _Phase.closed) { + sink.close().ignore(); + } + return sink; + } + + // --------------------------------------------------------------------------- + // Connecting. + + /// Opens the TCP connection to the proxy. + Future _init() async { + final task = + _connectTask = await RawSocket.startConnect(proxyHost, proxyPort); + // A close() or destroy() while the task was being created found nothing + // to cancel yet. if (_closeRequested) task.cancel(); + final RawSocket raw; try { - return await task.socket.timeout(_handshakeTimeout, onTimeout: () { + raw = await task.socket.timeout(_handshakeTimeout, onTimeout: () { task.cancel(); throw SocketException( 'Connection timed out, host: $proxyHost, port: $proxyPort'); @@ -451,194 +343,45 @@ class SOCKSSocket { } finally { _connectTask = null; } + _channel = RawChannel(raw); + _cancel = Completer()..future.ignore(); } - /// The peer stopped sending; writes already accepted are still delivered. - void _peerClosed() { - _peerReadClosed = true; - if (!_closeOnPeerEof) return; - _state = SocksSocketState.disconnected; - _ensureClosed().ignore(); - } - - void _destroyTransport() { - // After the TLS upgrade the secure channel owns and drains the raw socket. - (_secureChannel ?? _channel)?.destroy(); - // Sink completion does not cancel a paused addStream source after a reset. - // Destroy every native transport we own, even when its done has completed. - if (_nativeSocketNeedsDestroy) { - _nativeSocketNeedsDestroy = false; - _nativeSocketOpen = false; - _socksSocket.destroy(); - } - } - - /// Closes controllers directly; a paused input subscription never sees done. - void _abandonConnection() { - _destroyTransport(); - if (!_responseController.isClosed) _responseController.close(); - if (!_secureResponseController.isClosed) { - _secureResponseController.close(); - } - } - - /// Accumulates [expectedLength] bytes from the response stream. - Future> _waitForResponse(int expectedLength) => - _waitForReply((_) => expectedLength, allowTrailing: false); - - /// Accumulates a variable-length SOCKS5 connect response. - Future> _waitForConnectResponse() => - _waitForReply(socksConnectReplyLength, allowTrailing: true); - - Future> _waitForReply( - int? Function(List buffer) lengthOf, { - required bool allowTrailing, - }) { - final completer = Completer>(); - final buffer = []; - late final StreamSubscription> sub; - - void fail(String reason) { - sub.cancel(); - if (!completer.isCompleted) { - completer.completeError( - SocksHandshakeException( - proxyHost: proxyHost, - proxyPort: proxyPort, - message: 'SOCKS5 $reason (proxy: $proxyHost:$proxyPort).', - ), - ); - } - } - - sub = _handshakeResponses!.stream.listen( - (data) { - buffer.addAll(data); - final expectedLength = lengthOf(buffer); - if (buffer.length >= _maxHandshakeBuffer && - (expectedLength == null || buffer.length < expectedLength)) { - fail('handshake buffer overflow'); - return; - } - if (expectedLength == null || buffer.length < expectedLength) return; - if (expectedLength < 0) { - fail('reply is malformed'); - } else if (!allowTrailing && buffer.length > expectedLength) { - fail('reply has unexpected trailing data'); - } else { - sub.cancel(); - if (!completer.isCompleted) { - if (allowTrailing && expectedLength > 0 && !sslEnabled) { - _pendingApplicationData = buffer.sublist(expectedLength); - _pauseInput(); - } - completer.complete(buffer.sublist(0, expectedLength)); - } - } - }, - onError: (e) { - sub.cancel(); - if (!completer.isCompleted) { - completer.completeError(e); - } - }, - onDone: () { - if (!completer.isCompleted) { - completer.completeError( - SocksConnectionException( - message: 'Connection closed before SOCKS5 response received ' - '(proxy: $proxyHost:$proxyPort).', - ), - ); - } - }, - ); - - final dataFuture = completer.future.timeout( - _handshakeTimeout, - onTimeout: () { - sub.cancel(); - throw TimeoutException('SOCKS5 handshake timed out after ' - '${_handshakeTimeout.inSeconds} seconds.'); - }, - ); - - final cancel = _cancelCompleter; - if (cancel != null) { - return Future.any>([dataFuture, cancel.future]); - } - return dataFuture; - } - - static void _checkIsolationToken(String? token) { - if (token != null) encodeSocksCredentials(token, token); - } - - Future _writeHandshake(List bytes) async => - _socksSocket.add(bytes); - - /// Connects to the SOCKS socket. - /// - /// Returns: - /// A Future that resolves to void. + /// Negotiates the SOCKS5 greeting with the proxy. Future connect() async { _checkNotReconnecting(); return _connect(); } Future _connect() async { - if (_greetingStarted || - _closing || - _state != SocksSocketState.disconnected) { + if (_phase != _Phase.idle) { throw StateError( 'Cannot connect: use reconnect() for another connection'); } - _greetingStarted = true; - _cancelCompleter = Completer()..future.ignore(); - _state = SocksSocketState.connecting; + final channel = _channel; + if (channel == null) { + // The deprecated constructor does not await the proxy connection. + throw StateError('Cannot connect: the proxy connection is not open yet'); + } + _phase = _Phase.greeting; try { - await negotiateSocks( - write: _writeHandshake, - read: _waitForResponse, - credentials: _isolationToken == null - ? null - : encodeSocksCredentials(_isolationToken!, _isolationToken!), - allowNoAuthFallback: !(_requireIsolation ?? _isolationToken != null), - ); - if (_cancelled) { - throw SocksCancelledException(message: 'SOCKS5 connection cancelled.'); - } - _greetingComplete = true; - } on SocksProtocolFailure catch (error) { - _cancelCompleter = null; - if (_closing) throw _closedBeforeConnected(); - _state = SocksSocketState.error; - _abandonConnection(); - throw SocksHandshakeException( - proxyHost: proxyHost, - proxyPort: proxyPort, - message: 'SOCKS5 handshake failed: ${error.message}'); - } catch (e) { - _cancelCompleter = null; - if (e is SocksCancelledException) rethrow; - if (_closing) throw _closedBeforeConnected(); - _state = SocksSocketState.error; - _abandonConnection(); - rethrow; + await _handshake(() => negotiateSocks( + write: channel.write, + read: (count) => _readReply(channel, count), + credentials: _isolationToken == null + ? null + : encodeSocksCredentials(_isolationToken!, _isolationToken!), + allowNoAuthFallback: + !(_requireIsolation ?? _isolationToken != null), + )); + _phase = _Phase.greeted; + } catch (error) { + throw _handshakeFailed(error, 'SOCKS5 handshake failed: '); } } - SocksCancelledException _closedBeforeConnected() => SocksCancelledException( - message: 'SOCKS5 connection closed before it was established.'); - - /// Connects to the specified [domain] and [port] through the SOCKS socket. - /// - /// Parameters: - /// - [domain]: The domain to connect to. - /// - [port]: The port to connect to. - /// - /// Returns: - /// A Future that resolves to void. + /// Asks the proxy to connect to [domain]:[port], then negotiates TLS with + /// the target if [sslEnabled]. Future connectTo(String domain, int port) async { _checkNotReconnecting(); return _connectTo(domain, port); @@ -649,57 +392,25 @@ class SOCKSSocket { } Future _connectTo(String domain, int port) async { - if (_cancelled) { + if (_phase == _Phase.cancelled) { throw SocksCancelledException(message: 'SOCKS5 connection cancelled.'); } - if (_state != SocksSocketState.connecting || - !_greetingComplete || - _requestStarted || - _closing) { + if (_phase != _Phase.greeted) { throw StateError( 'Cannot connectTo: must call connect() and await it before one connectTo()'); } - final request = encodeSocksDestination(domain, port); - - _requestStarted = true; + _phase = _Phase.requesting; _targetDomain = domain; _targetPort = port; - + final channel = _channel!; try { - _socksSocket.add(request); - final response = await _waitForConnectResponse(); - try { - checkSocksConnectReply(response); - } on SocksProtocolFailure catch (error) { - if (error.replyCode != null) { - final replyCode = SocksReplyCode.fromByte(error.replyCode!); - throw SocksRequestException( - replyCode: replyCode, - proxyHost: proxyHost, - proxyPort: proxyPort, - targetDomain: domain, - targetPort: port, - message: 'SOCKS5 request failed (proxy: $proxyHost:$proxyPort, ' - 'target: $domain:$port, reply: ' - '${replyCode?.description ?? "unknown (0x${error.replyCode!.toRadixString(16).padLeft(2, '0')})"})', - ); - } - throw SocksHandshakeException( - proxyHost: proxyHost, proxyPort: proxyPort, message: error.message); - } - - // Upgrade to SSL if needed. + await _handshake(() async { + await channel.write(request); + checkSocksConnectReply(await _readConnectReply(channel)); + }); if (sslEnabled) { - final channel = _channel!; - final deadline = Completer()..future.ignore(); - final timer = Timer(_handshakeTimeout, () { - channel.destroy(); - deadline.completeError( - TimeoutException('SOCKS5 SSL handshake timed out after ' - '${_handshakeTimeout.inSeconds} seconds.')); - }); - final handshake = RawSecureSocket.secure( + final upgrade = RawSecureSocket.secure( channel.raw, subscription: channel.detach(), host: domain, @@ -707,73 +418,121 @@ class SOCKSSocket { onBadCertificate: _allowBadCertificates ? (_) => true : null, ); try { - // Upgrade to SSL. - final secured = await Future.any([ - handshake, - deadline.future, - if (_cancelCompleter != null) _cancelCompleter!.future, - ]); - final secureChannel = _secureChannel = RawChannel(secured); - _secureSocksSocket = secureChannel.socket(); - _sslUpgraded = true; - final responses = _secureResponseController; - - // Listen to the secure socket. - _subscription = _secureSocksSocket.listen( - (data) { - // Add the data to the response controller. - responses.add(data); - }, - onError: (Object e, StackTrace stack) { - final error = e is SocksException - ? e - : SocksConnectionException( - message: 'SOCKS5 connection error: $e', - ); - if (!responses.isClosed) responses.addError(error); - if (_state == SocksSocketState.connected) { - _failWrites(error, stack); - } - }, - onDone: () { - if (_state == SocksSocketState.connected) _peerClosed(); - if (!responses.isClosed) responses.close(); - }, - ); - _pauseInput(); - } catch (e) { - handshake.then((s) => s.close(), onError: (_) {}); - if (_cancelCompleter?.isCompleted == true) { - throw SocksCancelledException( - message: 'SOCKS5 connection cancelled during SSL upgrade.', - ); - } + _channel = RawChannel(await _handshake(() => upgrade)); + } catch (_) { + // The upgrade may still succeed after a timeout or cancellation. + upgrade.then((s) => s.close(), onError: (_) {}); rethrow; - } finally { - timer.cancel(); } } + } catch (error) { + throw _handshakeFailed(error, '', domain: domain, port: port); + } + final transport = _transport = _channel!.socket(); + _subscription = transport.listen( + _input.add, + onError: (Object e, StackTrace stack) { + final error = e is SocksException + ? e + : SocksConnectionException(message: 'SOCKS5 connection error: $e'); + if (!_input.isClosed) _input.addError(error); + if (_phase == _Phase.connected) _fail(error, stack, _Phase.failed); + }, + onDone: () { + if (_phase == _Phase.connected && _closeOnPeerEof) { + _ensureClosed().ignore(); + } + if (!_input.isClosed) _input.close(); + }, + ); + _phase = _Phase.connected; + _cancel = null; + if (!_input.hasListener) _subscription!.pause(); + } + + /// Runs one handshake [step] against the handshake deadline and the cancel + /// signal. A cancellation that lands as the step completes still wins. + Future _handshake(Future Function() step) async { + final cancel = _cancel!; + final result = await Future.any([step(), cancel.future]).timeout( + _handshakeTimeout, + onTimeout: () => throw TimeoutException('SOCKS5 handshake timed out ' + 'after ${_handshakeTimeout.inSeconds} seconds.'), + ); + if (cancel.isCompleted) await cancel.future; + return result; + } + + /// Reads a fixed-size greeting or authentication reply. Bytes beyond it + /// have no place in the protocol here, so they fail the handshake. + Future> _readReply(RawChannel channel, int count) async { + final reply = await channel.read(count); + if (channel.raw.available() > 0) { + throw const SocksProtocolFailure('reply has unexpected trailing data'); + } + return reply; + } + + /// Reads the variable-length CONNECT reply. Bytes after it belong to the + /// target and stay in the socket for [inputStream]. + Future> _readConnectReply(RawChannel channel) async { + final reply = [...await channel.read(4)]; + var length = socksConnectReplyLength(reply); + if (length == null) { + reply.addAll(await channel.read(1)); + length = socksConnectReplyLength(reply); + } + if (length != null && length > reply.length) { + reply.addAll(await channel.read(length - reply.length)); + } + return reply; + } - // Check if cancelled between handshake completion and state transition. - if (_cancelCompleter?.isCompleted == true) { - throw SocksCancelledException( - message: 'SOCKS5 connection cancelled.', + /// Tears down after a failed handshake step and maps [error] to what the + /// caller sees. Cancellation, [close] and [destroy] report as such. + Object _handshakeFailed(Object error, String prefix, + {String? domain, int? port}) { + _cancel = null; + if (error is SocksCancelledException) return error; + if (_phase == _Phase.cancelled) { + return SocksCancelledException(message: 'SOCKS5 connection cancelled.'); + } + if (_givenUp) return _closedBeforeConnected(); + _abandon(_Phase.failed); + if (error is SocksProtocolFailure) { + final replyCode = error.replyCode; + if (replyCode != null) { + final reply = SocksReplyCode.fromByte(replyCode); + return SocksRequestException( + replyCode: reply, + proxyHost: proxyHost, + proxyPort: proxyPort, + targetDomain: domain!, + targetPort: port!, + message: 'SOCKS5 request failed (proxy: $proxyHost:$proxyPort, ' + 'target: $domain:$port, reply: ' + '${reply?.description ?? "unknown (0x${replyCode.toRadixString(16).padLeft(2, '0')})"})', ); } - - _state = SocksSocketState.connected; - _cancelCompleter = null; - _resumeApplicationInput(); - } catch (e) { - _cancelCompleter = null; - if (e is SocksCancelledException) rethrow; - if (_closing) throw _closedBeforeConnected(); - _state = SocksSocketState.error; - _abandonConnection(); - rethrow; + return SocksHandshakeException( + proxyHost: proxyHost, + proxyPort: proxyPort, + message: '$prefix${error.message}'); + } + if (error is SocketException) { + return SocksConnectionException( + message: 'Connection closed before SOCKS5 response received ' + '(proxy: $proxyHost:$proxyPort): ${error.message}'); } + return error; } + SocksCancelledException _closedBeforeConnected() => SocksCancelledException( + message: 'SOCKS5 connection closed before it was established.'); + + // --------------------------------------------------------------------------- + // Writing. + /// Writes [object] to the socket. If [newline] is true, appends '\n'. Future write(Object? object, {bool newline = false}) async { if (object == null) return; @@ -781,31 +540,26 @@ class SOCKSSocket { await _queueWrite(newline ? [...data, 0x0A] : data); } - bool _canQueueWrite({bool allowClosing = false}) { - if (_closing && !allowClosing) return false; - // close() rejects new sink operations, but an already accepted addStream - // still feeds chunks after read-side EOF until it finishes or times out. - return _state == SocksSocketState.connected || - (allowClosing && - _closing && - _peerReadClosed && - _state == SocksSocketState.disconnected); - } + /// close() rejects new sink operations, but an addStream it accepted still + /// feeds chunks while output drains. + Future _queueSinkWrite(List data) => + _queueWrite(data, draining: _phase == _Phase.closing); - Future _queueWrite(List data, {bool allowClosing = false}) { - if (!_canQueueWrite(allowClosing: allowClosing)) { - return Future.error( - StateError('Cannot write: socket is not connected (state: $_state)')); + /// Throws synchronously when not writable, so a bound stream ends with the + /// [StateError] instead of failing the connection. + Future _queueWrite(List data, {bool draining = false}) { + if (!(_phase == _Phase.connected || draining)) { + throw StateError('Cannot write: socket is not connected (state: $state)'); } + final transport = _transport!; final bytes = List.of(data); - final generation = _generation; final writing = _writeTail.then((_) async { if (_writeFailure != null) { Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!); } try { - _socket.add(bytes); - await _socket.flush().timeout(_operationTimeout); + transport.add(bytes); + await transport.flush().timeout(_operationTimeout); // A transport destroyed mid-flush can still complete the flush. if (_writeFailure != null) { Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!); @@ -819,7 +573,9 @@ class SOCKSSocket { final error = e is SocksException || e is TimeoutException ? e : SocksConnectionException(message: 'SOCKS5 write failed: $e'); - if (generation == _generation) _failWrites(error, stack); + if (identical(transport, _transport)) { + _fail(error, stack, _Phase.failed); + } Error.throwWithStackTrace(error, stack); } }); @@ -828,21 +584,13 @@ class SOCKSSocket { return writing; } - void _failWrites(Object error, StackTrace stack) { - _writeFailure ??= error; - _writeFailureStack ??= stack; - _state = SocksSocketState.error; - _outputSink?._stop(error, stack); - _abandonConnection(); - } - - /// Whether SSL upgrade consumed the raw socket. - bool _sslUpgraded = false; + // --------------------------------------------------------------------------- + // Ending. - /// Closes the connection to the Tor proxy. + /// Drains accepted output, then closes the connection. /// - /// Returns: - /// A Future that resolves to void. + /// Rethrows a write failure that already ended the connection. A [close] + /// or [destroy] while [reconnect] is in progress cancels the reconnect. Future close() { _closeRequested = true; return _ensureClosed(); @@ -861,99 +609,85 @@ class SOCKSSocket { _signalCancel( SocksCancelledException(message: 'SOCKS5 connection destroyed.'), ); - _failWrites( + _fail( SocksConnectionException(message: 'SOCKS5 connection destroyed'), StackTrace.current, + _Phase.closed, ); - _state = SocksSocketState.disconnected; // A close already draining fails once its writes do; keep its outcome for // the caller that started it, but let a later close() complete normally. _closeFuture = (_closeFuture ?? _close()).catchError((Object _) {}); } + /// Cancels an in-flight connect operation. No-op if not connecting. + /// Once a target is known, [reconnect] opens a new connection; to end a + /// reconnect in progress, use [close] or [destroy]. + Future cancel() async { + if (!_connecting) return; + _signalCancel( + SocksCancelledException(message: 'SOCKS5 connection cancelled.'), + ); + _abandon(_Phase.cancelled); + _outputSink?.close().ignore(); + } + /// Ends a connect in flight. The TLS handshake cannot notice a destroyed /// transport on its own, so it waits for this or for its deadline. void _signalCancel(SocksCancelledException error) { _connectTask?.cancel(); - final cancel = _cancelCompleter; + final cancel = _cancel; if (cancel != null && !cancel.isCompleted) cancel.completeError(error); } + /// Records a failure, fails pending output, and tears down into [phase]. + void _fail(Object error, StackTrace stack, _Phase phase) { + _writeFailure ??= error; + _writeFailureStack ??= stack; + _outputSink?._stop(error, stack); + _abandon(phase); + } + + /// Destroys the transport and ends [inputStream] without draining; a paused + /// input subscription would never see done otherwise. A closed socket stays + /// closed: failures its teardown provokes are not news. + void _abandon(_Phase phase) { + if (_phase != _Phase.closed) _phase = phase; + _channel?.destroy(); + if (!_input.isClosed) _input.close(); + } + Future _close() async { - _closing = true; _signalCancel(_closedBeforeConnected()); - final upgraded = sslEnabled && _sslUpgraded; - final channel = upgraded ? _secureChannel : _channel; + _phase = switch (_phase) { + _Phase.connected => _Phase.closing, + _Phase.failed => _Phase.failed, + _ => _Phase.closed, + }; + final channel = _channel; + final transport = _transport; var flushed = false; - // Ensure all data is sent before closing. try { await _outputSink?.close().timeout(_operationTimeout); await _writeTail; - final open = _nativeSocketOpen || (channel != null && !channel.isClosed); - if (open && _writeFailure == null) { - await (upgraded ? _secureSocksSocket : _socksSocket) - .flush() - .timeout(_operationTimeout); + if (transport != null && + channel != null && + !channel.isClosed && + _writeFailure == null) { + await transport.flush().timeout(_operationTimeout); flushed = true; } } catch (error, stack) { _outputSink?._stop(error, stack); rethrow; } finally { - _generation++; - _state = SocksSocketState.disconnected; + _phase = _Phase.closed; if (flushed) { - await (upgraded ? _secureSocksSocket : _socksSocket) - .close() - .then((_) {}, onError: (_) {}); + await transport!.close().then((_) {}, onError: (_) {}); } await _subscription?.cancel(); - _destroyTransport(); - _closeHandshakeResponses(); - if (!_secureResponseController.isClosed) { - _secureResponseController.close(); - } - if (!_responseController.isClosed) { - _responseController.close(); - } - _sslUpgraded = false; - } - } - - /// Cancels an in-flight connect operation. No-op if not connecting. - /// Once a target is known, [reconnect] opens a new connection; to end a - /// reconnect in progress, use [close] or [destroy]. - Future cancel() async { - if (_state != SocksSocketState.connecting) return; - - final c = _cancelCompleter; - if (c == null || c.isCompleted) return; - - _cancelled = true; - _state = SocksSocketState.disconnected; - c.completeError( - SocksCancelledException( - message: 'SOCKS5 connection cancelled.', - ), - ); - - // Destroy socket to force pending operations to fail. - _destroyTransport(); - - await _subscription?.cancel(); - _outputSink?.close().ignore(); - _closeHandshakeResponses(); - if (!_responseController.isClosed) _responseController.close(); - if (!_secureResponseController.isClosed) { - _secureResponseController.close(); + channel?.destroy(); + if (!_input.isClosed) _input.close(); } - - _state = SocksSocketState.disconnected; - } - - void _closeHandshakeResponses() { - final handshake = _handshakeResponses; - if (handshake != null && !handshake.isClosed) handshake.close(); } /// Reconnects to the previously connected target. @@ -978,11 +712,8 @@ class SOCKSSocket { throw StateError('Cannot reconnect: no target known. ' 'Call connectTo() before reconnect().'); } - _checkIsolationToken(isolationToken); - if (isolationToken != null) { - _isolationToken = isolationToken; - } + if (isolationToken != null) _isolationToken = isolationToken; _closeRequested = false; try { @@ -993,15 +724,15 @@ class SOCKSSocket { _checkReconnectAborted(); // Broadcast controllers can't be reused after close. - _pendingApplicationData = null; - _responseController = _newResponseController(); - _secureResponseController = _newResponseController(); + _input = _newInput(); + _transport = null; + _subscription = null; _outputSink = null; _writeTail = Future.value(); _writeFailure = null; _writeFailureStack = null; _closeFuture = null; - _closing = false; + _phase = _Phase.idle; try { await _init(); @@ -1012,9 +743,7 @@ class SOCKSSocket { _checkReconnectAborted(); } catch (error) { final cancelled = error is SocksCancelledException || _closeRequested; - _state = - cancelled ? SocksSocketState.disconnected : SocksSocketState.error; - _abandonConnection(); + _abandon(cancelled ? _Phase.closed : _Phase.failed); // A cancelled TCP connect fails with a SocketException. if (cancelled && error is! SocksCancelledException) { throw _reconnectCancelled(); @@ -1035,34 +764,21 @@ class SOCKSSocket { Function? onError, void Function()? onDone, bool? cancelOnError, - }) { - return sslEnabled - ? _secureResponseController.stream.listen( - onData, - onError: onError, - onDone: onDone, - cancelOnError: cancelOnError, - ) - : _responseController.stream.listen( - onData, - onError: onError, - onDone: onDone, - cancelOnError: cancelOnError, - ); - } + }) => + _input.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); /// Sends the server.features command to the proxy server. /// /// This demos how to send the server.features command. Use as an example /// for sending other commands. - /// - /// Returns: - /// A Future that resolves to void. Future sendServerFeaturesCommand() async { - // The server.features command. const String command = '{"jsonrpc":"2.0","id":"0","method":"server.features","params":[]}'; - await write(command, newline: true); } } From a9e08e5f79ea5d3ff38b386f5ba92fca3e9e9786 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 14:34:19 -0500 Subject: [PATCH 08/20] feat!: remove the deprecated SOCKSSocket() constructor It has been deprecated since 1.2.0 because it does not await the proxy connection, so connect() raced _init(). Every consumer uses create(). BREAKING CHANGE: remove SOCKSSocket(); use SOCKSSocket.create(). --- CHANGELOG.md | 1 + lib/socks_socket.dart | 22 +--------------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37190b9..1fe429e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - **Breaking:** Remove `SOCKSSocket.socket` so transport operations cannot bypass the wrapper's write queue and lifecycle tracking. Use `destroy()`, `close()`, `write()`, `outputStream`, and `inputStream` instead. - **Breaking:** Caller-managed TLS through `SecureSocket.secure(socks.socket)` is no longer supported. Use `SOCKSSocket.create(sslEnabled: true, securityContext: ...)` to negotiate TLS during `connectTo()`. +- **Breaking:** Remove the `SOCKSSocket()` constructor, deprecated since 1.2.0; it never awaited the proxy connection. Use `SOCKSSocket.create()`. - Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. - Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3). - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 9dc577f..ffec2a6 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -234,22 +234,6 @@ class SOCKSSocket { return instance; } - /// Deprecated. Does not await the proxy connection; use [SOCKSSocket.create]. - @Deprecated('Use SOCKSSocket.create() instead') - SOCKSSocket({ - required this.proxyHost, - required this.proxyPort, - required this.sslEnabled, - }) : _isolationToken = null, - _handshakeTimeout = const Duration(seconds: 30), - _operationTimeout = const Duration(seconds: 30), - _allowBadCertificates = false, - _securityContext = null, - _requireIsolation = null, - _closeOnPeerEof = true { - _init(); - } - static void _checkIsolationToken(String? token) { if (token != null) encodeSocksCredentials(token, token); } @@ -358,12 +342,8 @@ class SOCKSSocket { throw StateError( 'Cannot connect: use reconnect() for another connection'); } - final channel = _channel; - if (channel == null) { - // The deprecated constructor does not await the proxy connection. - throw StateError('Cannot connect: the proxy connection is not open yet'); - } _phase = _Phase.greeting; + final channel = _channel!; try { await _handshake(() => negotiateSocks( write: channel.write, From 34ec799ea9beb4d2e601d6a7ae58dd4ab2b06ffc Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 16:59:31 -0500 Subject: [PATCH 09/20] feat!: remove the responseController and subscription getters They exposed the broadcast controller and the transport subscription behind inputStream. Closing the controller or cancelling the subscription from outside skipped the lifecycle tracking that 2.0.0 centralises, for the same reason the socket getter was removed. Nothing outside this package's own tests used them; those tests now check the delivered data rather than the subscription's pause state. --- CHANGELOG.md | 1 + README.md | 3 ++- lib/socks_socket.dart | 6 ------ test/input_lifecycle_test.dart | 6 ------ 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe429e..84f039e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - **Breaking:** Remove `SOCKSSocket.socket` so transport operations cannot bypass the wrapper's write queue and lifecycle tracking. Use `destroy()`, `close()`, `write()`, `outputStream`, and `inputStream` instead. - **Breaking:** Caller-managed TLS through `SecureSocket.secure(socks.socket)` is no longer supported. Use `SOCKSSocket.create(sslEnabled: true, securityContext: ...)` to negotiate TLS during `connectTo()`. - **Breaking:** Remove the `SOCKSSocket()` constructor, deprecated since 1.2.0; it never awaited the proxy connection. Use `SOCKSSocket.create()`. +- **Breaking:** Remove `responseController` and `subscription`, which exposed the stream controller and subscription behind `inputStream`; closing or cancelling them bypassed the lifecycle tracking. Use `inputStream`. - Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. - Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3). - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. diff --git a/README.md b/README.md index 0f16c6a..d963d21 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ By default the connection closes once the peer closes its side. Pass `closeOnPee ## Migrating to 2.0.0 -`SOCKSSocket.socket` has been removed. All transport operations now go through the wrapper so writes and teardown share the same lifecycle tracking. +`SOCKSSocket.socket`, `responseController` and `subscription` have been removed. All transport operations now go through the wrapper so writes and teardown share the same lifecycle tracking. | Previous operation | Replacement | | --- | --- | @@ -108,6 +108,7 @@ By default the connection closes once the peer closes its side. Pass `closeOnPee | `socks.socket.add(bytes)` | `socks.outputStream.add(bytes)` | | `socks.socket.addStream(source)` | `await socks.outputStream.addStream(source)` | | Reading the underlying socket | `socks.inputStream` or `socks.listen(...)` | +| `socks.responseController`, `socks.subscription` | `socks.inputStream`; pause or cancel your own subscription to it | | `SecureSocket.secure(socks.socket, ...)` | Set `sslEnabled: true` and, if needed, `securityContext` on `SOCKSSocket.create(...)` | For an awaited binary write, use `await socks.outputStream.addStream(Stream.value(bytes))`. The output sink accepts one stream at a time. For text, use `await socks.write(text)`. To drain and finish the connection, use `await socks.close()`. diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index ffec2a6..faec16d 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -273,12 +273,6 @@ class SOCKSSocket { /// Reads pause while there is no listener; earlier data is kept. Stream> get inputStream => _input.stream; - /// The controller behind [inputStream]. - StreamController> get responseController => _input; - - /// The subscription that feeds [inputStream], once connected. - StreamSubscription>? get subscription => _subscription; - StreamSink> get outputStream => _outputSink ??= _newOutputSink(); StreamController> _newInput() { diff --git a/test/input_lifecycle_test.dart b/test/input_lifecycle_test.dart index c7588ef..94bfa72 100644 --- a/test/input_lifecycle_test.dart +++ b/test/input_lifecycle_test.dart @@ -44,21 +44,16 @@ void main() { await socket.connect(); await socket.connectTo('localhost', 443); final peer = await accepted.future; - expect(socket.subscription!.isPaused, isTrue); peer.add([1]); await peer.flush(); expect(await socket.inputStream.first, [1]); - expect(socket.subscription!.isPaused, isTrue); peer.add([2]); await peer.flush(); expect(await socket.inputStream.first, [2]); - expect(socket.subscription!.isPaused, isTrue); final first = socket.inputStream.listen((_) {}); final second = socket.inputStream.listen((_) {}); await first.cancel(); - expect(socket.subscription!.isPaused, isFalse); await second.cancel(); - expect(socket.subscription!.isPaused, isTrue); }); test( @@ -83,7 +78,6 @@ void main() { await socket.reconnect(); final reply = socket.inputStream.first; await stale.cancel(); - expect(socket.subscription!.isPaused, isFalse); await socket.write('A'); expect(await reply.timeout(const Duration(seconds: 2)), [65]); }); From 2b7b5e0e590d710b3e79b58f3518a8311311aaac Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 8 Oct 2026 11:18:19 -0500 Subject: [PATCH 10/20] fix: resolve remaining lifecycle review findings --- CHANGELOG.md | 6 +- README.md | 8 +- lib/socks_socket.dart | 130 ++++++++++++++++++++++--------- lib/src/connection_socket.dart | 55 +++++++++++++ lib/src/socks_protocol.dart | 13 +++- test/connection_socket_test.dart | 41 ++++++++++ test/create_arguments_test.dart | 36 +++++++++ test/helpers/tunnel_peer.dart | 34 +------- test/output_lifecycle_test.dart | 29 ++++++- test/peer_eof_test.dart | 22 ++++++ test/socks_socket_test.dart | 17 ++++ test/teardown_race_test.dart | 28 +------ 12 files changed, 312 insertions(+), 107 deletions(-) create mode 100644 test/create_arguments_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f039e..5173032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,16 @@ ## 2.0.0 -- **Breaking:** Remove `SOCKSSocket.socket` so transport operations cannot bypass the wrapper's write queue and lifecycle tracking. Use `destroy()`, `close()`, `write()`, `outputStream`, and `inputStream` instead. +- **Breaking:** Remove `SOCKSSocket.socket` so transport operations cannot bypass the wrapper's write queue and lifecycle tracking. Use `destroy()`, `closeOutput()`, `close()`, `write()`, `outputStream`, and `inputStream` instead. `closeOutput()` preserves the native socket's write-side half-close behavior. - **Breaking:** Caller-managed TLS through `SecureSocket.secure(socks.socket)` is no longer supported. Use `SOCKSSocket.create(sslEnabled: true, securityContext: ...)` to negotiate TLS during `connectTo()`. - **Breaking:** Remove the `SOCKSSocket()` constructor, deprecated since 1.2.0; it never awaited the proxy connection. Use `SOCKSSocket.create()`. - **Breaking:** Remove `responseController` and `subscription`, which exposed the stream controller and subscription behind `inputStream`; closing or cancelling them bypassed the lifecycle tracking. Use `inputStream`. - Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. - Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3). - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. +- Give the greeting and username/password authentication exchanges separate handshake timeout budgets, preserving the previous slow-proxy behavior. +- Roll back `outputStream.addStream()` bookkeeping if the supplied stream rejects its subscription, so later output shutdown cannot wait forever. +- Make `close()` consistently rethrow a recorded write failure whether it came from `write()` or `outputStream`. +- Validate `SOCKSSocket.create()` proxy and timeout arguments before connecting. - Fail a handshake read or write at once when the socket reports its error synchronously, as macOS does for a reset peer, instead of waiting for the handshake deadline. `SocksConnection` shares the fix. - Document that `reconnect()` works after `cancel()`. - Drive `SOCKSSocket` from one private lifecycle phase instead of a dozen flags. No API change. `state` now reads `disconnected` as soon as `close()` starts draining, not only once it completes. diff --git a/README.md b/README.md index d963d21..4cea91a 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,11 @@ await socksSocket.write('{"jsonrpc":"2.0","method":"server.ping","id":1}', newline: true); ``` -After `cancel()` or `close()` during connect, `reconnect()` opens a new connection once a target is known. `reconnect()` closes the current connection first, which ends `inputStream`; a `close()` or `destroy()` while it is in progress cancels it, including one made from that stream's `onDone`. Peer close is noticed, and `state` updated, only while `inputStream` has a listener. +If `connect()` fails, the instance is spent: `state` is `error`, another `connect()` throws, and `reconnect()` has no target. Create a new instance. After a failed `connectTo()`, `reconnect()` retries that target. After `cancel()` or `close()` during connect, `reconnect()` opens a new connection once a target is known. `reconnect()` closes the current connection first, which ends `inputStream`; a `close()` or `destroy()` while it is in progress cancels it, including one made from that stream's `onDone`. Peer close is noticed, and `state` updated, only while `inputStream` has a listener. By default the connection closes once the peer closes its side. Pass `closeOnPeerEof: false` to `create()` to keep writing after a peer half-close until you call `close()`. -`destroy()` aborts a connection without draining output; pending writes fail, and a connect, TLS handshake or `reconnect()` in flight ends with `SocksCancelledException`. Use `close()` to drain accepted output before closing; it ends a connect or `reconnect()` in flight the same way. +`destroy()` aborts a connection without draining output; pending writes fail, and a connect, TLS handshake or `reconnect()` in flight ends with `SocksCancelledException`. Use `close()` to drain accepted output before closing; it ends a connect or `reconnect()` in flight the same way. When a protocol requires request EOF before the response, use `closeOutput()`, consume the response from `inputStream`, then call `close()`. ## Migrating to 2.0.0 @@ -104,14 +104,14 @@ By default the connection closes once the peer closes its side. Pass `closeOnPee | Previous operation | Replacement | | --- | --- | | `socks.socket.destroy()` | `socks.destroy()` | -| `socks.socket.close()` | `await socks.close()` | +| `socks.socket.close()` | `await socks.closeOutput()` to preserve input (the native half-close behavior), or `await socks.close()` to close both directions | | `socks.socket.add(bytes)` | `socks.outputStream.add(bytes)` | | `socks.socket.addStream(source)` | `await socks.outputStream.addStream(source)` | | Reading the underlying socket | `socks.inputStream` or `socks.listen(...)` | | `socks.responseController`, `socks.subscription` | `socks.inputStream`; pause or cancel your own subscription to it | | `SecureSocket.secure(socks.socket, ...)` | Set `sslEnabled: true` and, if needed, `securityContext` on `SOCKSSocket.create(...)` | -For an awaited binary write, use `await socks.outputStream.addStream(Stream.value(bytes))`. The output sink accepts one stream at a time. For text, use `await socks.write(text)`. To drain and finish the connection, use `await socks.close()`. +For an awaited binary write, use `await socks.outputStream.addStream(Stream.value(bytes))`. The output sink accepts one stream at a time. For text, use `await socks.write(text)`. To signal request EOF while continuing to receive, use `await socks.closeOutput()`. To drain and finish the entire connection, use `await socks.close()`. Built-in TLS starts during `connectTo()`, after the SOCKS handshake. Upgrading an established plaintext application session to TLS is not exposed by `SOCKSSocket`. diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index faec16d..7fb7493 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -89,6 +89,9 @@ enum _Phase { requesting, connected, + /// Output is shut down, but [SOCKSSocket.inputStream] may still receive. + receiving, + /// [SOCKSSocket.close] is draining accepted output. closing, @@ -164,7 +167,7 @@ class SOCKSSocket { StreamSubscription>? _subscription; /// The proxy TCP connect in flight, so [close] and [destroy] can end it. - ConnectionTask? _connectTask; + CancellableRawSocketConnect? _proxyConnect; /// Ends the handshake in flight; see [_signalCancel]. Completer? _cancel; @@ -177,6 +180,7 @@ class SOCKSSocket { StackTrace? _writeFailureStack; Future? _closeFuture; + Future? _closeOutputFuture; /// Set by [close] and [destroy]; a [reconnect] in flight stops at its next /// step instead of opening a connection the caller has already given up. @@ -212,6 +216,16 @@ class SOCKSSocket { bool? requireIsolation, bool closeOnPeerEof = true, }) async { + if (proxyHost.isEmpty) throw ArgumentError.value(proxyHost, 'proxyHost'); + if (proxyPort < 1 || proxyPort > 65535) { + throw ArgumentError.value(proxyPort, 'proxyPort'); + } + if (handshakeTimeout <= Duration.zero) { + throw ArgumentError.value(handshakeTimeout, 'handshakeTimeout'); + } + if (operationTimeout <= Duration.zero) { + throw ArgumentError.value(operationTimeout, 'operationTimeout'); + } _checkIsolationToken(isolationToken); if (requireIsolation == true && isolationToken == null) { throw ArgumentError( @@ -253,7 +267,7 @@ class SOCKSSocket { _Phase.greeted || _Phase.requesting => SocksSocketState.connecting, - _Phase.connected => SocksSocketState.connected, + _Phase.connected || _Phase.receiving => SocksSocketState.connected, _Phase.failed => SocksSocketState.error, }; @@ -295,7 +309,9 @@ class SOCKSSocket { }); if (_writeFailure != null) { sink._stop(_writeFailure!, _writeFailureStack!); - } else if (_phase == _Phase.closing || _phase == _Phase.closed) { + } else if (_phase == _Phase.receiving || + _phase == _Phase.closing || + _phase == _Phase.closed) { sink.close().ignore(); } return sink; @@ -306,20 +322,13 @@ class SOCKSSocket { /// Opens the TCP connection to the proxy. Future _init() async { - final task = - _connectTask = await RawSocket.startConnect(proxyHost, proxyPort); - // A close() or destroy() while the task was being created found nothing - // to cancel yet. - if (_closeRequested) task.cancel(); + final operation = _proxyConnect = CancellableRawSocketConnect(); + if (_closeRequested) operation.cancel(); final RawSocket raw; try { - raw = await task.socket.timeout(_handshakeTimeout, onTimeout: () { - task.cancel(); - throw SocketException( - 'Connection timed out, host: $proxyHost, port: $proxyPort'); - }); + raw = await operation.connect(proxyHost, proxyPort, _handshakeTimeout); } finally { - _connectTask = null; + if (identical(_proxyConnect, operation)) _proxyConnect = null; } _channel = RawChannel(raw); _cancel = Completer()..future.ignore(); @@ -339,15 +348,18 @@ class SOCKSSocket { _phase = _Phase.greeting; final channel = _channel!; try { - await _handshake(() => negotiateSocks( - write: channel.write, - read: (count) => _readReply(channel, count), - credentials: _isolationToken == null - ? null - : encodeSocksCredentials(_isolationToken!, _isolationToken!), - allowNoAuthFallback: - !(_requireIsolation ?? _isolationToken != null), - )); + await negotiateSocks( + write: channel.write, + read: (count) => _readReply(channel, count), + exchange: (request, count) => _handshake(() async { + await channel.write(request); + return _readReply(channel, count); + }), + credentials: _isolationToken == null + ? null + : encodeSocksCredentials(_isolationToken!, _isolationToken!), + allowNoAuthFallback: !(_requireIsolation ?? _isolationToken != null), + ); _phase = _Phase.greeted; } catch (error) { throw _handshakeFailed(error, 'SOCKS5 handshake failed: '); @@ -410,10 +422,13 @@ class SOCKSSocket { ? e : SocksConnectionException(message: 'SOCKS5 connection error: $e'); if (!_input.isClosed) _input.addError(error); - if (_phase == _Phase.connected) _fail(error, stack, _Phase.failed); + if (_phase == _Phase.connected || _phase == _Phase.receiving) { + _fail(error, stack, _Phase.failed); + } }, onDone: () { - if (_phase == _Phase.connected && _closeOnPeerEof) { + if ((_phase == _Phase.connected || _phase == _Phase.receiving) && + _closeOnPeerEof) { _ensureClosed().ignore(); } if (!_input.isClosed) _input.close(); @@ -572,6 +587,32 @@ class SOCKSSocket { Future _ensureClosed() => _closeFuture ??= _close(); + /// Drains accepted output and shuts down only the sending direction. + /// + /// [inputStream] remains open so a peer that waits for request EOF before + /// replying can still deliver its response. Call [close] after consuming + /// that response to release the remaining connection resources. + Future closeOutput() { + if (_phase == _Phase.receiving) { + return _closeOutputFuture ?? Future.value(); + } + if (_phase != _Phase.connected) { + throw StateError( + 'Cannot close output: socket is not connected (state: $state)'); + } + return _closeOutputFuture ??= _closeOutput(); + } + + Future _closeOutput() async { + final transport = _transport!; + await _outputSink?.close().timeout(_operationTimeout); + await _writeTail; + _throwWriteFailure(); + await transport.flush().timeout(_operationTimeout); + await transport.close(); + if (_phase == _Phase.connected) _phase = _Phase.receiving; + } + /// Tears the connection down at once, without draining output. /// /// Writes not yet delivered, through [write] or [outputStream], fail with a @@ -608,7 +649,7 @@ class SOCKSSocket { /// Ends a connect in flight. The TLS handshake cannot notice a destroyed /// transport on its own, so it waits for this or for its deadline. void _signalCancel(SocksCancelledException error) { - _connectTask?.cancel(); + _proxyConnect?.cancel(); final cancel = _cancel; if (cancel != null && !cancel.isCompleted) cancel.completeError(error); } @@ -633,7 +674,7 @@ class SOCKSSocket { Future _close() async { _signalCancel(_closedBeforeConnected()); _phase = switch (_phase) { - _Phase.connected => _Phase.closing, + _Phase.connected || _Phase.receiving => _Phase.closing, _Phase.failed => _Phase.failed, _ => _Phase.closed, }; @@ -641,14 +682,17 @@ class SOCKSSocket { final transport = _transport; var flushed = false; try { - await _outputSink?.close().timeout(_operationTimeout); - await _writeTail; - if (transport != null && - channel != null && - !channel.isClosed && - _writeFailure == null) { - await transport.flush().timeout(_operationTimeout); - flushed = true; + final closingOutput = _closeOutputFuture; + if (closingOutput != null) { + await closingOutput; + } else { + await _outputSink?.close().timeout(_operationTimeout); + await _writeTail; + _throwWriteFailure(); + if (transport != null && channel != null && !channel.isClosed) { + await transport.flush().timeout(_operationTimeout); + flushed = true; + } } } catch (error, stack) { _outputSink?._stop(error, stack); @@ -664,6 +708,12 @@ class SOCKSSocket { } } + void _throwWriteFailure() { + if (_writeFailure != null) { + Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!); + } + } + /// Reconnects to the previously connected target. /// /// Closes the current connection first, which ends [inputStream]. A [close] @@ -706,6 +756,7 @@ class SOCKSSocket { _writeFailure = null; _writeFailureStack = null; _closeFuture = null; + _closeOutputFuture = null; _phase = _Phase.idle; try { @@ -820,8 +871,13 @@ class _SocketOutputSink implements StreamSink> { @override Future addStream(Stream> stream) { _checkOpen(); - final completed = _streamDone = Completer(); - final source = _source = stream.listen(null, cancelOnError: true); + final completed = Completer(); + // listen() is allowed to throw synchronously (for example when a + // single-subscription stream was already consumed). Do not publish the + // pending stream state until the subscription was accepted. + final source = stream.listen(null, cancelOnError: true); + _streamDone = completed; + _source = source; source ..onData((List data) { source.pause(); diff --git a/lib/src/connection_socket.dart b/lib/src/connection_socket.dart index 3b73751..31cc539 100644 --- a/lib/src/connection_socket.dart +++ b/lib/src/connection_socket.dart @@ -3,6 +3,61 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +abstract interface class RawSocketConnectTask { + Future get socket; + void cancel(); +} + +final class _IoRawSocketConnectTask implements RawSocketConnectTask { + final ConnectionTask _task; + _IoRawSocketConnectTask(this._task); + + @override + Future get socket => _task.socket; + + @override + void cancel() => _task.cancel(); +} + +typedef RawSocketStarter = Future Function( + String host, int port); + +Future _startRawSocket(String host, int port) async => + _IoRawSocketConnectTask(await RawSocket.startConnect(host, port)); + +/// One cancellable attempt to open a raw socket. +/// +/// Cancellation is sticky, including while [RawSocket.startConnect] is still +/// creating its [ConnectionTask]. This class is public only within `src/` so +/// the timing-sensitive behavior can be tested without relying on a host's TCP +/// listen-backlog behavior. +class CancellableRawSocketConnect { + final RawSocketStarter _start; + RawSocketConnectTask? _task; + bool _cancelled = false; + + CancellableRawSocketConnect([RawSocketStarter start = _startRawSocket]) + : _start = start; + + Future connect(String host, int port, Duration timeout) async { + final task = _task = await _start(host, port); + if (_cancelled) task.cancel(); + try { + return await task.socket.timeout(timeout, onTimeout: () { + task.cancel(); + throw SocketException('Connection timed out, host: $host, port: $port'); + }); + } finally { + _task = null; + } + } + + void cancel() { + _cancelled = true; + _task?.cancel(); + } +} + class RawChannel { final RawSocket raw; late final StreamSubscription _subscription; diff --git a/lib/src/socks_protocol.dart b/lib/src/socks_protocol.dart index 305bf51..be6d5b3 100644 --- a/lib/src/socks_protocol.dart +++ b/lib/src/socks_protocol.dart @@ -63,12 +63,18 @@ int? socksConnectReplyLength(List reply) { Future negotiateSocks({ required Future Function(List) write, required Future> Function(int) read, + Future> Function(List, int)? exchange, List? credentials, bool allowNoAuthFallback = false, }) async { + Future> requestReply(List request, int replyLength) async { + if (exchange != null) return exchange(request, replyLength); + await write(request); + return read(replyLength); + } + final methods = credentials == null ? [0] : [if (allowNoAuthFallback) 0, 2]; - await write([5, methods.length, ...methods]); - final greeting = await read(2); + final greeting = await requestReply([5, methods.length, ...methods], 2); if (greeting[0] != 5) { throw const SocksProtocolFailure('invalid reply version'); } @@ -84,8 +90,7 @@ Future negotiateSocks({ throw const SocksProtocolFailure('proxy rejected authentication method'); } if (greeting[1] == 2) { - await write(credentials!); - final auth = await read(2); + final auth = await requestReply(credentials!, 2); if (auth[0] != 1 || auth[1] != 0) { throw const SocksProtocolFailure('username/password auth rejected', rejected: true); diff --git a/test/connection_socket_test.dart b/test/connection_socket_test.dart index ea9170a..b1e205f 100644 --- a/test/connection_socket_test.dart +++ b/test/connection_socket_test.dart @@ -54,6 +54,24 @@ class _PendingWriteSocket extends Stream implements RawSocket { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _FakeRawSocketConnectTask implements RawSocketConnectTask { + final Completer result; + bool cancelled = false; + + _FakeRawSocketConnectTask(this.result); + + @override + Future get socket => result.future; + + @override + void cancel() { + cancelled = true; + if (!result.isCompleted) { + result.completeError(const SocketException('cancelled')); + } + } +} + /// A socket that fails its next read or write synchronously: dart:io delivers /// the error through the event stream and closes the socket before the call /// returns, so no event follows. A plain socket reports a read failure this @@ -122,6 +140,29 @@ class _ResetSocket extends Stream implements RawSocket { } void main() { + for (final delayedStart in [false, true]) { + test( + 'cancellable connect cancels ${delayedStart ? 'before' : 'after'} the task is created', + () async { + final start = Completer(); + final socket = Completer(); + socket.future.ignore(); + final task = _FakeRawSocketConnectTask(socket); + final connector = CancellableRawSocketConnect((_, __) async { + if (delayedStart) await start.future; + return task; + }); + final connecting = connector.connect(InternetAddress.loopbackIPv4.address, + 1080, const Duration(seconds: 2)); + final failed = expectLater(connecting, throwsA(isA())); + if (!delayedStart) await Future.value(); + connector.cancel(); + if (delayedStart) start.complete(); + await failed.timeout(const Duration(seconds: 1)); + expect(task.cancelled, isTrue); + }); + } + test('flush followed by close delivers the complete TCP payload', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); addTearDown(server.close); diff --git a/test/create_arguments_test.dart b/test/create_arguments_test.dart new file mode 100644 index 0000000..9d8170f --- /dev/null +++ b/test/create_arguments_test.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:socks_socket/socks_socket.dart'; +import 'package:test/test.dart'; + +void main() { + final host = InternetAddress.loopbackIPv4.address; + + test('create() rejects an empty proxy host', () async { + await expectLater(SOCKSSocket.create(proxyHost: '', proxyPort: 1080), + throwsArgumentError); + }); + + for (final port in [0, 65536]) { + test('create() rejects proxy port $port', () async { + await expectLater(SOCKSSocket.create(proxyHost: host, proxyPort: port), + throwsArgumentError); + }); + } + + test('create() rejects a non-positive handshake timeout', () async { + await expectLater( + SOCKSSocket.create( + proxyHost: host, proxyPort: 1080, handshakeTimeout: Duration.zero), + throwsArgumentError); + }); + + test('create() rejects a non-positive operation timeout', () async { + await expectLater( + SOCKSSocket.create( + proxyHost: host, + proxyPort: 1080, + operationTimeout: const Duration(seconds: -1)), + throwsArgumentError); + }); +} diff --git a/test/helpers/tunnel_peer.dart b/test/helpers/tunnel_peer.dart index f46fec6..3ec3d9e 100644 --- a/test/helpers/tunnel_peer.dart +++ b/test/helpers/tunnel_peer.dart @@ -53,10 +53,7 @@ class TunnelServer { final TestCertificates? certificates; final _peers = StreamController(); late final _accepted = StreamIterator(_peers.stream); - late final StreamSubscription _accepting; late final int port = _server.port; - RawServerSocket? _blocked; - final _fillers = >[]; int connections = 0; /// Delays the greeting reply, so a client stays in connect(). @@ -70,7 +67,7 @@ class TunnelServer { TunnelHold? holdTls; TunnelServer._(this._server, this.certificates) { - _accepting = _server.listen(_serve); + _server.listen(_serve); } bool get tls => certificates != null; @@ -149,30 +146,6 @@ class TunnelServer { return accepted; } - /// Replaces the listener with one that never accepts and fills its - /// backlog, so a later TCP connect to [port] stays pending. Returns false - /// when this host completes such connects anyway. - Future stall() async { - await _accepting.cancel(); - await _server.close(); - try { - _blocked = await RawServerSocket.bind(InternetAddress.loopbackIPv4, port, - backlog: 1); - } on SocketException { - return false; - } - for (var i = 0; i < 4; i++) { - final task = - await Socket.startConnect(InternetAddress.loopbackIPv4, port); - _fillers.add(task); - final connected = task.socket - .then((_) => true, onError: (Object _) => false) - .timeout(const Duration(milliseconds: 300), onTimeout: () => false); - if (!await connected) return true; - } - return false; - } - /// The next tunnel the server completed. Future nextPeer() async { if (!await _accepted.moveNext().timeout(tunnelDeadline)) { @@ -219,12 +192,7 @@ class TunnelServer { for (final hold in [holdGreeting, holdConnect, holdTls]) { if (hold != null && !hold.release.isCompleted) hold.release.complete(); } - for (final filler in _fillers) { - filler.cancel(); - filler.socket.then((s) => s.destroy(), onError: (Object _) {}); - } await _server.close(); - await _blocked?.close(); await _accepted.cancel(); // Without a listener the controller's close() would never complete. _peers.close().ignore(); diff --git a/test/output_lifecycle_test.dart b/test/output_lifecycle_test.dart index 54a710b..508e24a 100644 --- a/test/output_lifecycle_test.dart +++ b/test/output_lifecycle_test.dart @@ -121,6 +121,31 @@ void main() { await socket.outputStream.close(); }); + test('a rejected addStream does not leave output shutdown pending', () async { + final proxy = MockSocksServer(); + await proxy.start(); + addTearDown(proxy.stop); + final socket = await SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: proxy.port, + ); + addTearDown(() => socket.close().catchError((_) {})); + await socket.connect(); + await socket.connectTo('localhost', 443); + + final source = StreamController>(); + addTearDown(source.close); + final first = source.stream.listen((_) {}); + addTearDown(first.cancel); + expect( + () => socket.outputStream.addStream(source.stream), throwsStateError); + + final reply = socket.inputStream.first; + socket.outputStream.add([65]); + expect(await reply.timeout(const Duration(seconds: 2)), [65]); + await socket.close().timeout(const Duration(seconds: 2)); + }); + test('output during the handshake does not abort it', () async { final proxy = MockSocksServer() ..responseDelay = const Duration(milliseconds: 100); @@ -245,6 +270,7 @@ void main() { } expect(socket.state, ConnectionState.error); await expectLater(socket.write('B'), throwsStateError); + await expectLater(socket.close(), throwsA(isA())); }); test('a write failing after reconnect does not affect the new connection', @@ -322,7 +348,7 @@ void main() { proxyPort: server.port, operationTimeout: const Duration(milliseconds: 100), ); - addTearDown(socket.close); + addTearDown(() => socket.close().catchError((_) {})); await socket.connect(); await socket.connectTo('localhost', 443); final inputDone = socket.inputStream.drain(); @@ -334,5 +360,6 @@ void main() { await inputDone; expect(socket.state, ConnectionState.error); await expectLater(socket.write('Z'), throwsStateError); + await expectLater(socket.close(), throwsA(isA())); }); } diff --git a/test/peer_eof_test.dart b/test/peer_eof_test.dart index c6a795f..1bbf831 100644 --- a/test/peer_eof_test.dart +++ b/test/peer_eof_test.dart @@ -14,6 +14,28 @@ final _solSocket = Platform.isMacOS ? 0xffff : 1; final _soLinger = Platform.isMacOS ? 0x80 : 13; void main() { + for (final tls in [false, true]) { + test('closeOutput preserves a response sent after request EOF (TLS=$tls)', + () async { + final (client, peer) = await connectTunnel(tls: tls); + final response = client.inputStream.first; + await client.write('request'); + await client.closeOutput().timeout(_deadline); + await client.closeOutput().timeout(_deadline); + await expectLater(client.write('late'), throwsStateError); + expect(() => client.outputStream.add([0]), throwsStateError); + await peer.done.future.timeout(_deadline); + expect(peer.bytes.takeBytes(), 'request'.codeUnits); + + peer.socket.add([42]); + await peer.socket.flush(); + expect(await response.timeout(_deadline), [42]); + expect(client.state, SocksSocketState.connected); + await client.close().timeout(_deadline); + expect(client.state, SocksSocketState.disconnected); + }); + } + for (final tls in [false, true]) { test('peer EOF drains a pending write (TLS=$tls)', () async { final (client, peer) = await connectTunnel(tls: tls); diff --git a/test/socks_socket_test.dart b/test/socks_socket_test.dart index 6d71c0c..eb803d1 100644 --- a/test/socks_socket_test.dart +++ b/test/socks_socket_test.dart @@ -859,6 +859,23 @@ void main() { await socket.close(); }); + test('authentication receives a fresh handshake timeout', () async { + server + ..requireAuth = true + ..responseDelay = const Duration(milliseconds: 150); + await server.start(); + + final socket = await SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: server.port, + isolationToken: 'slow-auth', + handshakeTimeout: const Duration(milliseconds: 250), + ); + await socket.connect().timeout(const Duration(seconds: 2)); + expect(socket.state, ConnectionState.connecting); + await socket.close(); + }); + test('reconnect with different isolationToken rotates circuit', () async { server.requireAuth = true; await server.start(); diff --git a/test/teardown_race_test.dart b/test/teardown_race_test.dart index 9f4836e..d31ac69 100644 --- a/test/teardown_race_test.dart +++ b/test/teardown_race_test.dart @@ -54,29 +54,6 @@ void main() { await client.close().timeout(tunnelDeadline); }); - test('while the proxy TCP connect is pending', () async { - final server = await TunnelServer.start(tls: tls); - final (client, peer) = await server.connect( - handshakeTimeout: const Duration(seconds: 2)); - if (!await server.stall()) { - markTestSkipped('This host completes connects beyond the backlog'); - return; - } - final reconnecting = client.reconnect(); - final failed = expectLater( - reconnecting, throwsA(isA())); - await peer.done.future.timeout(tunnelDeadline); - await Future.delayed(const Duration(milliseconds: 100)); - final stopwatch = Stopwatch()..start(); - final tearingDown = teardown(client); - await failed.timeout(tunnelDeadline); - expect(stopwatch.elapsed, lessThan(const Duration(seconds: 1))); - await tearingDown.timeout(tunnelDeadline); - expect(client.state, SocksSocketState.disconnected); - await expectLater(client.write('A'), throwsStateError); - await client.close().timeout(tunnelDeadline); - }); - test('from the old inputStream ending', () async { // reconnect() creates the connect task a few microtasks after the // old connection's close completes, and the old inputStream's done @@ -88,10 +65,7 @@ void main() { for (var i = 0; i < microtasks; i++) await server.connect(handshakeTimeout: const Duration(seconds: 2)) ]; - if (!await server.stall()) { - markTestSkipped('This host completes connects beyond the backlog'); - return; - } + server.holdGreeting = TunnelHold(); for (var delay = 0; delay < microtasks; delay++) { final (client, _) = clients[delay]; final stopwatch = Stopwatch(); From ac70c2ec3e6100479aba32db0a4199174ddd2f3b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 8 Oct 2026 11:18:19 -0500 Subject: [PATCH 11/20] fix: make bundled examples consume network responses --- example/lib/main.dart | 5 +++++ example/macos/Runner/DebugProfile.entitlements | 2 ++ example/macos/Runner/Release.entitlements | 4 ++++ example/socks_socket_example.dart | 7 ++++++- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index a14b185..45f3f18 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -180,7 +180,12 @@ class _MyAppState extends State { 'bitcoin.stackwallet.com', 50002); // Send a server features command to the connected socket, see method for more specific usage example.. + final response = socksSocket.inputStream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .first; await socksSocket.sendServerFeaturesCommand(); + print(await response); // You should see a server response printed to the console. // diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements index dddb8a3..3ba6c12 100644 --- a/example/macos/Runner/DebugProfile.entitlements +++ b/example/macos/Runner/DebugProfile.entitlements @@ -6,6 +6,8 @@ com.apple.security.cs.allow-jit + com.apple.security.network.client + com.apple.security.network.server diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements index 852fa1a..7a2230d 100644 --- a/example/macos/Runner/Release.entitlements +++ b/example/macos/Runner/Release.entitlements @@ -4,5 +4,9 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.network.server + diff --git a/example/socks_socket_example.dart b/example/socks_socket_example.dart index e9b7c21..4c9c9a7 100644 --- a/example/socks_socket_example.dart +++ b/example/socks_socket_example.dart @@ -2,7 +2,7 @@ // // See the [Arti package](https://pub.dev/packages/arti) for a pure Dart implementation. -import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:socks_socket/socks_socket.dart'; @@ -25,6 +25,11 @@ Future main() async { // Send a server features command to the connected socket, see method for // more specific usage example.. + final response = socksSocket.inputStream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .first; await socksSocket.sendServerFeaturesCommand(); + print(await response); await socksSocket.close(); } From f0e4077dae4e1eb390d713105dc7c81839aadae0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 8 Oct 2026 12:05:05 -0500 Subject: [PATCH 12/20] fix: make output shutdown reject writes and clean up failures --- CHANGELOG.md | 1 + README.md | 2 +- lib/socks_socket.dart | 57 +++++++++---- test/close_output_test.dart | 160 ++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 test/close_output_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5173032..70424b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **Breaking:** Remove `responseController` and `subscription`, which exposed the stream controller and subscription behind `inputStream`; closing or cancelling them bypassed the lifecycle tracking. Use `inputStream`. - Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. - Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3). +- `closeOutput()` rejects new writes as soon as draining starts, preserves accepted uploads, and aborts the connection and pending output if draining fails or times out. - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. - Give the greeting and username/password authentication exchanges separate handshake timeout budgets, preserving the previous slow-proxy behavior. - Roll back `outputStream.addStream()` bookkeeping if the supplied stream rejects its subscription, so later output shutdown cannot wait forever. diff --git a/README.md b/README.md index 4cea91a..a2193fa 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ By default the connection closes once the peer closes its side. Pass `closeOnPee | `socks.responseController`, `socks.subscription` | `socks.inputStream`; pause or cancel your own subscription to it | | `SecureSocket.secure(socks.socket, ...)` | Set `sslEnabled: true` and, if needed, `securityContext` on `SOCKSSocket.create(...)` | -For an awaited binary write, use `await socks.outputStream.addStream(Stream.value(bytes))`. The output sink accepts one stream at a time. For text, use `await socks.write(text)`. To signal request EOF while continuing to receive, use `await socks.closeOutput()`. To drain and finish the entire connection, use `await socks.close()`. +For an awaited binary write, use `await socks.outputStream.addStream(Stream.value(bytes))`. The output sink accepts one stream at a time. For text, use `await socks.write(text)`. To signal request EOF while continuing to receive, use `await socks.closeOutput()`. It immediately rejects new writes while draining accepted output; a drain timeout or transport failure aborts the connection and fails pending output. To drain and finish the entire connection, use `await socks.close()`. Built-in TLS starts during `connectTo()`, after the SOCKS handshake. Upgrading an established plaintext application session to TLS is not exposed by `SOCKSSocket`. diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 7fb7493..1037341 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -89,6 +89,9 @@ enum _Phase { requesting, connected, + /// [SOCKSSocket.closeOutput] is draining accepted output; input stays open. + closingOutput, + /// Output is shut down, but [SOCKSSocket.inputStream] may still receive. receiving, @@ -267,7 +270,10 @@ class SOCKSSocket { _Phase.greeted || _Phase.requesting => SocksSocketState.connecting, - _Phase.connected || _Phase.receiving => SocksSocketState.connected, + _Phase.connected || + _Phase.closingOutput || + _Phase.receiving => + SocksSocketState.connected, _Phase.failed => SocksSocketState.error, }; @@ -309,7 +315,8 @@ class SOCKSSocket { }); if (_writeFailure != null) { sink._stop(_writeFailure!, _writeFailureStack!); - } else if (_phase == _Phase.receiving || + } else if (_phase == _Phase.closingOutput || + _phase == _Phase.receiving || _phase == _Phase.closing || _phase == _Phase.closed) { sink.close().ignore(); @@ -422,12 +429,16 @@ class SOCKSSocket { ? e : SocksConnectionException(message: 'SOCKS5 connection error: $e'); if (!_input.isClosed) _input.addError(error); - if (_phase == _Phase.connected || _phase == _Phase.receiving) { + if (_phase == _Phase.connected || + _phase == _Phase.closingOutput || + _phase == _Phase.receiving) { _fail(error, stack, _Phase.failed); } }, onDone: () { - if ((_phase == _Phase.connected || _phase == _Phase.receiving) && + if ((_phase == _Phase.connected || + _phase == _Phase.closingOutput || + _phase == _Phase.receiving) && _closeOnPeerEof) { _ensureClosed().ignore(); } @@ -529,10 +540,10 @@ class SOCKSSocket { await _queueWrite(newline ? [...data, 0x0A] : data); } - /// close() rejects new sink operations, but an addStream it accepted still - /// feeds chunks while output drains. - Future _queueSinkWrite(List data) => - _queueWrite(data, draining: _phase == _Phase.closing); + /// close() and closeOutput() reject new sink operations, but an addStream + /// they accepted still feeds chunks while output drains. + Future _queueSinkWrite(List data) => _queueWrite(data, + draining: _phase == _Phase.closing || _phase == _Phase.closingOutput); /// Throws synchronously when not writable, so a bound stream ends with the /// [StateError] instead of failing the connection. @@ -589,11 +600,14 @@ class SOCKSSocket { /// Drains accepted output and shuts down only the sending direction. /// + /// Rejects new writes immediately while an already accepted upload drains. /// [inputStream] remains open so a peer that waits for request EOF before /// replying can still deliver its response. Call [close] after consuming /// that response to release the remaining connection resources. + /// A drain timeout or transport failure aborts the connection and fails + /// pending output; [close] also reports that failure. Future closeOutput() { - if (_phase == _Phase.receiving) { + if (_phase == _Phase.closingOutput || _phase == _Phase.receiving) { return _closeOutputFuture ?? Future.value(); } if (_phase != _Phase.connected) { @@ -604,13 +618,21 @@ class SOCKSSocket { } Future _closeOutput() async { + _phase = _Phase.closingOutput; final transport = _transport!; - await _outputSink?.close().timeout(_operationTimeout); - await _writeTail; - _throwWriteFailure(); - await transport.flush().timeout(_operationTimeout); - await transport.close(); - if (_phase == _Phase.connected) _phase = _Phase.receiving; + try { + await _outputSink?.close().timeout(_operationTimeout); + await _writeTail; + _throwWriteFailure(); + await transport.flush().timeout(_operationTimeout); + await transport.close(); + if (_phase == _Phase.closingOutput) _phase = _Phase.receiving; + } catch (error, stack) { + // Preserve a failure already recorded by a write or destroy(). + _throwWriteFailure(); + _fail(error, stack, _Phase.failed); + rethrow; + } } /// Tears the connection down at once, without draining output. @@ -674,7 +696,10 @@ class SOCKSSocket { Future _close() async { _signalCancel(_closedBeforeConnected()); _phase = switch (_phase) { - _Phase.connected || _Phase.receiving => _Phase.closing, + _Phase.connected || + _Phase.closingOutput || + _Phase.receiving => + _Phase.closing, _Phase.failed => _Phase.failed, _ => _Phase.closed, }; diff --git a/test/close_output_test.dart b/test/close_output_test.dart new file mode 100644 index 0000000..4455cd1 --- /dev/null +++ b/test/close_output_test.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:socks_socket/socks_socket.dart'; +import 'package:test/test.dart'; + +import 'helpers/tunnel_peer.dart'; + +void main() { + for (final tls in [false, true]) { + test('closeOutput rejects a racing write and a new output sink (TLS=$tls)', + () async { + final (client, peer) = await connectTunnel(tls: tls); + final response = client.inputStream.first; + response.ignore(); + await client.write('request'); + + final closing = client.closeOutput(); + closing.ignore(); + expect(() => client.outputStream.add([0]), throwsStateError); + final late = Future.microtask(() => client.write('late')); + await expectLater(late, throwsStateError); + await closing.timeout(tunnelDeadline); + await peer.done.future.timeout(tunnelDeadline); + expect(peer.bytes.takeBytes(), 'request'.codeUnits); + + peer.socket.add([42]); + await peer.socket.flush(); + expect(await response.timeout(tunnelDeadline), [42]); + await client.close().timeout(tunnelDeadline); + }); + + test('closeOutput rejects new writes while draining an upload (TLS=$tls)', + () async { + final (client, peer) = await connectTunnel(tls: tls); + final source = StreamController>(); + addTearDown(() async { + client.destroy(); + await source.close(); + }); + final response = client.inputStream.first; + response.ignore(); + final output = client.outputStream; + final uploading = output.addStream(source.stream); + uploading.ignore(); + source.add([65]); + await peer.firstChunk.future.timeout(tunnelDeadline); + + final closing = client.closeOutput(); + closing.ignore(); + await expectLater(client.write('late'), throwsStateError); + expect(() => output.add([0]), throwsStateError); + expect(() => output.addStream(Stream.value([0])), throwsStateError); + source.add([66]); + await source.close(); + await Future.wait([uploading, closing, client.closeOutput()]) + .timeout(tunnelDeadline); + await peer.done.future.timeout(tunnelDeadline); + expect(peer.bytes.takeBytes(), [65, 66]); + + peer.socket.add([42]); + await peer.socket.flush(); + expect(await response.timeout(tunnelDeadline), [42]); + expect(client.state, SocksSocketState.connected); + await client.close().timeout(tunnelDeadline); + }); + + test('closeOutput timeout cancels an unfinished upload (TLS=$tls)', + () async { + final (client, peer) = await connectTunnel( + tls: tls, + operationTimeout: const Duration(milliseconds: 100), + ); + final source = StreamController>(); + addTearDown(() async { + client.destroy(); + await source.close(); + }); + final inputDone = client.inputStream.drain(); + final output = client.outputStream; + final uploading = output.addStream(source.stream); + uploading.ignore(); + source.add([65]); + await peer.firstChunk.future.timeout(tunnelDeadline); + + await expectLater(client.closeOutput().timeout(tunnelDeadline), + throwsA(isA())); + expect(source.hasListener, isFalse); + await expectLater(uploading, throwsA(isA())); + await expectLater(output.done, throwsA(isA())); + await inputDone.timeout(tunnelDeadline); + await peer.done.future.timeout(tunnelDeadline); + expect(client.state, SocksSocketState.error); + await expectLater(client.write('late'), throwsStateError); + await expectLater(client.close().timeout(tunnelDeadline), + throwsA(isA())); + }); + + for (final peerEof in [false, true]) { + test( + '${peerEof ? 'peer EOF' : 'close()'} during closeOutput drains the upload (TLS=$tls)', + () async { + final (client, peer) = await connectTunnel(tls: tls); + final inputDone = client.inputStream.drain(); + final source = StreamController>(); + addTearDown(() async { + client.destroy(); + await source.close(); + }); + final uploading = client.outputStream.addStream(source.stream); + uploading.ignore(); + source.add([65]); + await peer.firstChunk.future.timeout(tunnelDeadline); + final closingOutput = client.closeOutput(); + closingOutput.ignore(); + if (peerEof) { + await peer.socket.close(); + await inputDone.timeout(tunnelDeadline); + } + final closing = client.close(); + closing.ignore(); + expect(client.state, SocksSocketState.disconnected); + source.add([66]); + await source.close(); + await Future.wait([uploading, closingOutput, closing]) + .timeout(tunnelDeadline); + await inputDone.timeout(tunnelDeadline); + await peer.done.future.timeout(tunnelDeadline); + expect(peer.bytes.takeBytes(), [65, 66]); + }); + } + + test( + 'destroy() interrupts closeOutput and a later close succeeds (TLS=$tls)', + () async { + final (client, peer) = await connectTunnel(tls: tls); + final source = StreamController>(); + addTearDown(() async { + client.destroy(); + await source.close(); + }); + final inputDone = client.inputStream.drain(); + final uploading = client.outputStream.addStream(source.stream); + uploading.ignore(); + source.add([65]); + await peer.firstChunk.future.timeout(tunnelDeadline); + final closingOutput = client.closeOutput(); + closingOutput.ignore(); + client.destroy(); + + final destroyed = throwsA(isA() + .having((error) => error.message, 'message', contains('destroyed'))); + await expectLater(uploading, destroyed); + await expectLater(closingOutput, destroyed); + await client.close().timeout(tunnelDeadline); + await inputDone.timeout(tunnelDeadline); + expect(source.hasListener, isFalse); + expect(client.state, SocksSocketState.disconnected); + }); + } +} From 3775dcd7c587c70e5ad3ac27d37bef305d8fb297 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 17:03:05 -0500 Subject: [PATCH 13/20] chore: stop tracking IDE files --- .gitignore | 3 + .idea/.gitignore | 3 - .idea/libraries/Dart_Packages.xml | 582 ------------------------------ .idea/libraries/Dart_SDK.xml | 29 -- .idea/misc.xml | 6 - .idea/modules.xml | 8 - .idea/socks_socket.iml | 13 - .idea/vcs.xml | 6 - 8 files changed, 3 insertions(+), 647 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/libraries/Dart_Packages.xml delete mode 100644 .idea/libraries/Dart_SDK.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/socks_socket.iml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index 3cceda5..86410c5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ # Avoid committing pubspec.lock for library packages; see # https://dart.dev/guides/libraries/private-files#pubspeclock. pubspec.lock + +# IDE files +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml deleted file mode 100644 index ee95adb..0000000 --- a/.idea/libraries/Dart_Packages.xml +++ /dev/null @@ -1,582 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml deleted file mode 100644 index fcbce96..0000000 --- a/.idea/libraries/Dart_SDK.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 639900d..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index f49cd95..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/socks_socket.iml b/.idea/socks_socket.iml deleted file mode 100644 index dff7377..0000000 --- a/.idea/socks_socket.iml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 979495e6ff547ed8dfdaafb5fd5303200c7b0f1f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 17:03:05 -0500 Subject: [PATCH 14/20] docs: fix the README badges and make the snippets self-contained The badge block was indented, so it rendered as code; the license badge pointed at another repository and its link was unterminated; the snippets used Tor.instance without saying where it comes from. --- README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a2193fa..7b1542f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SOCKS sockets - [![Pub](https://img.shields.io/pub/v/socks_socket.svg)](https://pub.dev/packages/socks_socket) - [![GitHub](https://img.shields.io/github/license/stackdump/socks_socket)]( +[![Pub](https://img.shields.io/pub/v/socks_socket.svg)](https://pub.dev/packages/socks_socket) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) SOCKS version 5 sockets for Dart and Flutter, *eg.* ElectrumX and/or Fulcrum over Tor via socket(s). @@ -25,12 +25,18 @@ SOCKS version 5 sockets for Dart and Flutter, *eg.* ElectrumX and/or Fulcrum ove See `socks_socket.dart` itself for properties and methods and the example for reference. ```dart +import 'dart:io'; + import 'package:socks_socket/socks.dart'; +// The SOCKS5 port of your Tor instance, for example Tor.instance.port when +// Tor runs in-app through package:tor_ffi_plugin. +const proxyPort = 9050; + // Instantiate a socks socket at localhost and on the port selected by the tor service. var socksSocket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: Tor.instance.port, + proxyPort: proxyPort, sslEnabled: true, // For SSL connections. ); @@ -54,7 +60,7 @@ await socksSocket.sendServerFeaturesCommand(); // Configure custom timeouts for slow networks like Tor. var socksSocket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: Tor.instance.port, + proxyPort: proxyPort, sslEnabled: true, handshakeTimeout: Duration(seconds: 60), operationTimeout: Duration(seconds: 45), @@ -67,7 +73,7 @@ var socksSocket = await SOCKSSocket.create( // Use isolationToken to request a separate Tor circuit. var socksSocket = await SOCKSSocket.create( proxyHost: InternetAddress.loopbackIPv4.address, - proxyPort: Tor.instance.port, + proxyPort: proxyPort, sslEnabled: true, isolationToken: 'wallet-btc-001', ); From 588976e33272d99e7232d2e5841bc370fadfdab2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 17:04:15 -0500 Subject: [PATCH 15/20] fix: report sub-second handshake timeouts accurately The timeout message printed whole seconds, so a 500 ms deadline read "timed out after 0 seconds". --- CHANGELOG.md | 4 ++++ lib/socks_socket.dart | 9 +++++++-- test/create_arguments_test.dart | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70424b1..5668239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- The handshake timeout message reports sub-second deadlines in milliseconds instead of "0 seconds". + ## 2.0.0 - **Breaking:** Remove `SOCKSSocket.socket` so transport operations cannot bypass the wrapper's write queue and lifecycle tracking. Use `destroy()`, `closeOutput()`, `close()`, `write()`, `outputStream`, and `inputStream` instead. `closeOutput()` preserves the native socket's write-side half-close behavior. diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 1037341..164e953 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -450,14 +450,19 @@ class SOCKSSocket { if (!_input.hasListener) _subscription!.pause(); } + static String _describe(Duration duration) => + duration.inMilliseconds % 1000 == 0 + ? '${duration.inSeconds} s' + : '${duration.inMilliseconds} ms'; + /// Runs one handshake [step] against the handshake deadline and the cancel /// signal. A cancellation that lands as the step completes still wins. Future _handshake(Future Function() step) async { final cancel = _cancel!; final result = await Future.any([step(), cancel.future]).timeout( _handshakeTimeout, - onTimeout: () => throw TimeoutException('SOCKS5 handshake timed out ' - 'after ${_handshakeTimeout.inSeconds} seconds.'), + onTimeout: () => throw TimeoutException( + 'SOCKS5 handshake timed out after ${_describe(_handshakeTimeout)}.'), ); if (cancel.isCompleted) await cancel.future; return result; diff --git a/test/create_arguments_test.dart b/test/create_arguments_test.dart index 9d8170f..a64d38e 100644 --- a/test/create_arguments_test.dart +++ b/test/create_arguments_test.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'dart:io'; import 'package:socks_socket/socks_socket.dart'; import 'package:test/test.dart'; +import 'helpers/tunnel_peer.dart'; + void main() { final host = InternetAddress.loopbackIPv4.address; @@ -33,4 +36,15 @@ void main() { operationTimeout: const Duration(seconds: -1)), throwsArgumentError); }); + + test('a sub-second handshake timeout is reported in milliseconds', () async { + final server = await TunnelServer.start(); + server.holdGreeting = TunnelHold(); + final client = await server.createClient( + handshakeTimeout: const Duration(milliseconds: 500)); + await expectLater( + client.connect(), + throwsA(isA() + .having((e) => e.message, 'message', contains('500 ms')))); + }); } From b5d0f0ec33d3008309ce902ee7bf76aaf082211f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 17:04:37 -0500 Subject: [PATCH 16/20] example: use the collision-free entrypoint and pin tor_ffi_plugin The Flutter app imported the library file that defines ConnectionState beside Flutter's own, which socks.dart exists to avoid. The tor_ffi_plugin git dependency had no ref, so builds were not reproducible. The example README was Flutter boilerplate with a TODO. --- example/README.md | 32 ++++++++++++++------------------ example/lib/main.dart | 2 +- example/pubspec.yaml | 9 +++------ 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/example/README.md b/example/README.md index 5a75e39..c7718a0 100644 --- a/example/README.md +++ b/example/README.md @@ -1,22 +1,18 @@ -# socks_socket_example +# socks_socket example -Demonstrates how to use the socks_socket package. Uses cypherstack/tor for the tor service. +A Flutter app that starts Tor in-process with `tor_ffi_plugin`, opens a +`SOCKSSocket` through it, and talks to an ElectrumX server over the proxy. It +also shows the same request through a general-purpose SOCKS5 client package +for comparison. -## TODO +Run it with `flutter run` on a desktop or mobile target. The first start +downloads Tor's consensus, which can take a minute. -- Show how/why the other packages available are not suitable for this use case. +Two smaller, non-Flutter examples live beside it: -## Getting Started - -Run as in `flutter run`. - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +- `socks_socket_example.dart`: the socket API without the UI. It still needs + Flutter for the Tor plugin; run it from a Flutter project, or point + `proxyPort` at a Tor you run yourself. +- `http/http_connection.dart`: `SocksConnection.start` as an + `HttpClient.connectionFactory`, runnable with plain Dart against any local + SOCKS5 proxy. See the main README for the command. diff --git a/example/lib/main.dart b/example/lib/main.dart index 45f3f18..7802cbe 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:socks_socket/socks_socket.dart'; +import 'package:socks_socket/socks.dart'; // Imports needed for tor usage: import 'package:socks5_proxy/socks_client.dart'; // Just for example; can use any socks5 proxy package, pick your favorite. import 'package:tor_ffi_plugin/tor_ffi_plugin.dart'; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index c5c90b7..f880af6 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -34,12 +34,9 @@ dependencies: socks_socket: path: .. tor_ffi_plugin: - # When depending on this package from a real application you should use: - # tor_ffi_plugin: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. - git: https://github.com/cypherstack/tor # TODO: rework example app to use user-provided Tor proxy. + git: + url: https://github.com/cypherstack/tor + ref: 5586bfa3c48e495bc00f9b56fa18869daf768573 # native-0.1.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. From c06b174ea58e9406240cf563ab3c8c430b9ddc14 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 8 Oct 2026 13:38:14 -0500 Subject: [PATCH 17/20] fix: release sockets after both directions close --- lib/src/connection_socket.dart | 33 ++++++++++++++++++++++++-------- test/connection_edges_test.dart | 2 ++ test/connection_socket_test.dart | 30 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/lib/src/connection_socket.dart b/lib/src/connection_socket.dart index 31cc539..53f1986 100644 --- a/lib/src/connection_socket.dart +++ b/lib/src/connection_socket.dart @@ -66,6 +66,7 @@ class RawChannel { Completer? _writable; bool _closed = false; bool _readClosed = false; + bool _writeClosed = false; bool _detached = false; bool _streaming = false; Object? _error; @@ -92,11 +93,7 @@ class RawChannel { } }, onCancel: () { - if (!_readClosed && !_closed && !_detached) { - _readClosed = true; - raw.readEventsEnabled = false; - raw.shutdown(SocketDirection.receive); - } + _closeRead(); }, ); _subscription = raw.listen(_event, onError: (Object error) { @@ -125,6 +122,7 @@ class RawChannel { _readable?.complete(); _readable = null; if (_streaming && !_incoming.isClosed) _incoming.close(); + if (_writeClosed) destroy(); } else if (event == RawSocketEvent.closed) { destroy(); } @@ -191,6 +189,27 @@ class RawChannel { return raw is RawSecureSocket ? _TlsSocket(this) : _ConnectionSocket(this); } + void _closeRead() { + if (_readClosed || _closed || _detached) return; + _readClosed = true; + raw.readEventsEnabled = false; + try { + raw.shutdown(SocketDirection.receive); + } finally { + if (_writeClosed) destroy(); + } + } + + void _closeWrite() { + if (_writeClosed || _closed) return; + _writeClosed = true; + try { + raw.shutdown(SocketDirection.send); + } finally { + if (_readClosed) destroy(); + } + } + void destroy() { if (_closed) return; _closed = true; @@ -248,9 +267,7 @@ class _RawConsumer implements StreamConsumer> { } @override - Future close() async { - if (!channel._closed) channel.raw.shutdown(SocketDirection.send); - } + Future close() async => channel._closeWrite(); } class _ConnectionSocket extends StreamView implements Socket { diff --git a/test/connection_edges_test.dart b/test/connection_edges_test.dart index 1bf9ecf..951b711 100644 --- a/test/connection_edges_test.dart +++ b/test/connection_edges_test.dart @@ -23,6 +23,8 @@ void main() { expect( await proxy.applicationData.future.timeout(const Duration(seconds: 2)), [99]); + await socket.close(); + await proxy.disconnected.future.timeout(const Duration(seconds: 2)); }); test('upload stream errors close transport and retain the original error', diff --git a/test/connection_socket_test.dart b/test/connection_socket_test.dart index b1e205f..1da9f79 100644 --- a/test/connection_socket_test.dart +++ b/test/connection_socket_test.dart @@ -10,6 +10,8 @@ class _PendingWriteSocket extends Stream implements RawSocket { final writeStarted = Completer(); bool pendingWrite = false; bool closedWithPendingWrite = false; + bool closed = false; + final shutdowns = []; @override bool readEventsEnabled = false; @@ -40,11 +42,13 @@ class _PendingWriteSocket extends Stream implements RawSocket { @override void shutdown(SocketDirection direction) { + shutdowns.add(direction); closedWithPendingWrite |= pendingWrite; } @override Future close() async { + closed = true; closedWithPendingWrite |= pendingWrite; events.close(); return this; @@ -140,6 +144,32 @@ class _ResetSocket extends Stream implements RawSocket { } void main() { + for (final inputFirst in [true, false]) { + test( + 'channel closes after both directions close (${inputFirst ? 'input' : 'output'} first)', + () async { + final raw = _PendingWriteSocket(); + final socket = RawChannel(raw).socket(); + final input = socket.listen((_) {}); + + if (inputFirst) { + await input.cancel(); + expect(raw.shutdowns, [SocketDirection.receive]); + expect(raw.closed, isFalse); + await socket.close(); + } else { + await socket.close(); + expect(raw.shutdowns, [SocketDirection.send]); + expect(raw.closed, isFalse); + await input.cancel(); + } + + expect(raw.shutdowns, + containsAll([SocketDirection.receive, SocketDirection.send])); + expect(raw.closed, isTrue); + }); + } + for (final delayedStart in [false, true]) { test( 'cancellable connect cancels ${delayedStart ? 'before' : 'after'} the task is created', From 82920b1b455ac29c9a3785f47d0db9f1ed10c820 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 8 Oct 2026 13:38:14 -0500 Subject: [PATCH 18/20] fix: preserve write failures from closeOutput --- lib/socks_socket.dart | 7 +++++-- test/output_lifecycle_test.dart | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index 164e953..41ca9a1 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -612,14 +612,17 @@ class SOCKSSocket { /// A drain timeout or transport failure aborts the connection and fails /// pending output; [close] also reports that failure. Future closeOutput() { + final closing = _closeOutputFuture; + if (closing != null) return closing; + _throwWriteFailure(); if (_phase == _Phase.closingOutput || _phase == _Phase.receiving) { - return _closeOutputFuture ?? Future.value(); + return Future.value(); } if (_phase != _Phase.connected) { throw StateError( 'Cannot close output: socket is not connected (state: $state)'); } - return _closeOutputFuture ??= _closeOutput(); + return _closeOutputFuture = _closeOutput(); } Future _closeOutput() async { diff --git a/test/output_lifecycle_test.dart b/test/output_lifecycle_test.dart index 508e24a..cf8d4cb 100644 --- a/test/output_lifecycle_test.dart +++ b/test/output_lifecycle_test.dart @@ -94,6 +94,7 @@ void main() { socket.outputStream.addError(error); await done; expect(socket.state, ConnectionState.error); + expect(() => socket.closeOutput(), throwsA(same(error))); }); }); } From 608901f1a284c28f1879d157888ae43575a78034 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 8 Oct 2026 13:38:14 -0500 Subject: [PATCH 19/20] example: modernize Android Gradle configuration --- example/android/app/build.gradle | 20 +++++-------- .../android/app/src/main/AndroidManifest.xml | 2 ++ example/android/build.gradle | 13 -------- example/android/settings.gradle | 30 ++++++++++++++----- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 4c908eb..587878c 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,3 +1,10 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must follow the Android and Kotlin plugins. + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +13,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,10 +23,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { namespace "com.example.example" compileSdkVersion flutter.compileSdkVersion @@ -66,7 +64,3 @@ android { flutter { source '../..' } - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 19b862e..2c04d0a 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + properties.load(reader) } +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.7.10" apply false +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +include ':app' From ec62ce9986a1ee2b4777cc1d089cafc62a856b1d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 8 Oct 2026 13:38:14 -0500 Subject: [PATCH 20/20] docs: consolidate the 2.0.0 changelog --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5668239..91e5e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,3 @@ -## Unreleased - -- The handshake timeout message reports sub-second deadlines in milliseconds instead of "0 seconds". - ## 2.0.0 - **Breaking:** Remove `SOCKSSocket.socket` so transport operations cannot bypass the wrapper's write queue and lifecycle tracking. Use `destroy()`, `closeOutput()`, `close()`, `write()`, `outputStream`, and `inputStream` instead. `closeOutput()` preserves the native socket's write-side half-close behavior. @@ -9,18 +5,23 @@ - **Breaking:** Remove the `SOCKSSocket()` constructor, deprecated since 1.2.0; it never awaited the proxy connection. Use `SOCKSSocket.create()`. - **Breaking:** Remove `responseController` and `subscription`, which exposed the stream controller and subscription behind `inputStream`; closing or cancelling them bypassed the lifecycle tracking. Use `inputStream`. - Add `destroy()` to abort without draining output; writes it cuts short fail instead of reporting success (#2). It also ends a connect, TLS handshake or `reconnect()` in flight, and a later `close()` completes normally even when it failed a close that was already draining. +- Add `closeOutput()` to drain and half-close the sending direction while preserving input for protocols that respond after request EOF. - Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3). - `closeOutput()` rejects new writes as soon as draining starts, preserves accepted uploads, and aborts the connection and pending output if draining fails or times out. - `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline. - Give the greeting and username/password authentication exchanges separate handshake timeout budgets, preserving the previous slow-proxy behavior. - Roll back `outputStream.addStream()` bookkeeping if the supplied stream rejects its subscription, so later output shutdown cannot wait forever. - Make `close()` consistently rethrow a recorded write failure whether it came from `write()` or `outputStream`. +- Make `closeOutput()` rethrow the same recorded write failure as `close()` instead of replacing it with a state error. +- Release the underlying socket after callers close both input and output, while still allowing writes between input cancellation and output shutdown. - Validate `SOCKSSocket.create()` proxy and timeout arguments before connecting. +- The handshake timeout message reports sub-second deadlines in milliseconds instead of "0 seconds". - Fail a handshake read or write at once when the socket reports its error synchronously, as macOS does for a reset peer, instead of waiting for the handshake deadline. `SocksConnection` shares the fix. - Document that `reconnect()` works after `cancel()`. - Drive `SOCKSSocket` from one private lifecycle phase instead of a dozen flags. No API change. `state` now reads `disconnected` as soon as `close()` starts draining, not only once it completes. - Carry the plain connection over the same raw channel as TLS and `SocksConnection`, so transport errors read the same on both transports. - Read handshake replies straight from the channel, as `SocksConnection` does. A greeting or authentication reply with trailing bytes still fails the handshake, and a stray byte before the CONNECT reply now fails it too instead of being dropped. Handshake failure messages come from the shared protocol code. +- Migrate the Android example to Flutter's declarative Gradle plugins and grant release builds network access. ## 1.4.0