Skip to content

Commit 68760ef

Browse files
committed
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.
1 parent 7699736 commit 68760ef

7 files changed

Lines changed: 76 additions & 116 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
## Unreleased
1+
## 2.0.0
22

3+
- **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.
4+
- **Breaking:** Caller-managed TLS through `SecureSocket.secure(socks.socket)` is no longer supported. Use `SOCKSSocket.create(sslEnabled: true, securityContext: ...)` to negotiate TLS during `connectTo()`.
35
- 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.
46
- Add `closeOnPeerEof` to `create()`; pass `false` to keep writing after the peer half-closes (#3).
57
- `close()` now ends a `reconnect()` or TLS handshake in flight instead of being overtaken by the reconnect or waiting for the handshake deadline.

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,25 @@ By default the connection closes once the peer closes its side. Pass `closeOnPee
9797

9898
`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.
9999

100+
## Migrating to 2.0.0
101+
102+
`SOCKSSocket.socket` has been removed. All transport operations now go through the wrapper so writes and teardown share the same lifecycle tracking.
103+
104+
| Previous operation | Replacement |
105+
| --- | --- |
106+
| `socks.socket.destroy()` | `socks.destroy()` |
107+
| `socks.socket.close()` | `await socks.close()` |
108+
| `socks.socket.add(bytes)` | `socks.outputStream.add(bytes)` |
109+
| `socks.socket.addStream(source)` | `await socks.outputStream.addStream(source)` |
110+
| Reading the underlying socket | `socks.inputStream` or `socks.listen(...)` |
111+
| `SecureSocket.secure(socks.socket, ...)` | Set `sslEnabled: true` and, if needed, `securityContext` on `SOCKSSocket.create(...)` |
112+
113+
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()`.
114+
115+
Built-in TLS starts during `connectTo()`, after the SOCKS handshake. Upgrading an established plaintext application session to TLS is not exposed by `SOCKSSocket`.
116+
117+
`SocksConnection.start` still returns a `ConnectionTask<Socket>` for `HttpClient.connectionFactory`; that separate API is unchanged.
118+
100119
## HttpClient Connections
101120

102121
`SocksConnection.start` returns a cancellable `ConnectionTask<Socket>` for `HttpClient.connectionFactory`; see `example/http/http_connection.dart`. Pass `tlsHost` for HTTPS. Requires Dart 3.5.

‎lib/socks_socket.dart‎

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,7 @@ class SOCKSSocket {
131131
/// The underlying Socket that connects to the SOCKS5 proxy server.
132132
late Socket _socksSocket;
133133

134-
/// Getter for the underlying Socket that connects to the SOCKS5 proxy server.
135-
///
136-
/// To abort the connection, call [destroy] rather than `socket.destroy()`:
137-
/// dart:io completes a pending flush on a destroyed [Socket] normally, so
138-
/// writes cut short that way would be reported as sent.
139-
Socket get socket => sslEnabled ? _secureSocksSocket : _socksSocket;
134+
Socket get _socket => sslEnabled ? _secureSocksSocket : _socksSocket;
140135

141136
/// A wrapper around the _socksSocket that enables SSL connections.
142137
late Socket _secureSocksSocket;
@@ -402,7 +397,6 @@ class SOCKSSocket {
402397
_channel = RawChannel(raw);
403398
_socksSocket = _channel!.socket();
404399
} else {
405-
// Keep a native Socket so callers can use SecureSocket.secure(socket).
406400
_socksSocket = await _connectProxy(Socket.startConnect);
407401
_nativeSocketOpen = true;
408402
_nativeSocketNeedsDestroy = true;
@@ -807,8 +801,8 @@ class SOCKSSocket {
807801
Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!);
808802
}
809803
try {
810-
socket.add(bytes);
811-
await socket.flush().timeout(_operationTimeout);
804+
_socket.add(bytes);
805+
await _socket.flush().timeout(_operationTimeout);
812806
// A transport destroyed mid-flush can still complete the flush.
813807
if (_writeFailure != null) {
814808
Error.throwWithStackTrace(_writeFailure!, _writeFailureStack!);
@@ -869,32 +863,6 @@ class SOCKSSocket {
869863
_closeFuture = (_closeFuture ?? _close()).catchError((Object _) {});
870864
}
871865

872-
Future<void> _flushBeforeClose(Socket transport) async {
873-
final elapsed = Stopwatch()..start();
874-
while (true) {
875-
final remaining = _operationTimeout - elapsed.elapsed;
876-
if (remaining <= Duration.zero) {
877-
throw TimeoutException(
878-
'SOCKS5 output did not drain before close', _operationTimeout);
879-
}
880-
final Future flushing;
881-
try {
882-
flushing = transport.flush();
883-
} on StateError {
884-
// The public native socket may have an active flush/addStream outside
885-
// _writeTail. IOSink rejects another flush synchronously while bound,
886-
// and exposes no future for that operation. Keep its transport alive
887-
// and retry within one deadline; wrapping it would break manual TLS.
888-
const interval = Duration(milliseconds: 10);
889-
await Future<void>.delayed(remaining < interval ? remaining : interval);
890-
continue;
891-
}
892-
// Async failures belong to the flush itself and must not be retried.
893-
await flushing.timeout(remaining);
894-
return;
895-
}
896-
}
897-
898866
/// Ends a connect in flight. The TLS handshake cannot notice a destroyed
899867
/// transport on its own, so it waits for this or for its deadline.
900868
void _signalCancel(SocksCancelledException error) {
@@ -915,7 +883,9 @@ class SOCKSSocket {
915883
await _writeTail;
916884
final open = _nativeSocketOpen || (channel != null && !channel.isClosed);
917885
if (open && _writeFailure == null) {
918-
await _flushBeforeClose(upgraded ? _secureSocksSocket : _socksSocket);
886+
await (upgraded ? _secureSocksSocket : _socksSocket)
887+
.flush()
888+
.timeout(_operationTimeout);
919889
flushed = true;
920890
}
921891
} catch (error, stack) {

‎pubspec.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: socks_socket
22
description: SOCKS version 5 sockets for Dart and Flutter, *eg.* ElectrumX and/or Fulcrum over Tor via socket(s).
3-
version: 1.4.0
3+
version: 2.0.0
44
repository: https://github.com/cypherstack/socks_socket
55

66
environment:

‎test/connection_lifecycle_test.dart‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ void main() {
8282
});
8383

8484
test('peer EOF still delivers accepted writes', () async {
85-
const size = 1024 * 1024;
85+
const size = 8 * 1024 * 1024;
8686
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
8787
addTearDown(server.close);
8888
final received = Completer<int>();
@@ -119,8 +119,6 @@ void main() {
119119
addTearDown(() => uploader.close().catchError((_) {}));
120120
await uploader.connect();
121121
await uploader.connectTo('localhost', 80);
122-
// Linux SO_SNDBUF.
123-
uploader.socket.setRawOption(RawSocketOption.fromInt(1, 7, 65536));
124122
final input = uploader.inputStream.drain<void>();
125123
await uploader.write('A' * size);
126124
await input;

‎test/peer_eof_test.dart‎

Lines changed: 40 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -9,52 +9,30 @@ import 'helpers/tunnel_peer.dart';
99

1010
const _deadline = tunnelDeadline;
1111

12-
// SOL_SOCKET, SO_SNDBUF and SO_LINGER differ between Linux and macOS.
12+
// SOL_SOCKET and SO_LINGER differ between Linux and macOS.
1313
final _solSocket = Platform.isMacOS ? 0xffff : 1;
14-
final _soSndbuf = Platform.isMacOS ? 0x1001 : 7;
1514
final _soLinger = Platform.isMacOS ? 0x80 : 13;
1615

1716
void main() {
18-
test('peer EOF drains a pending underlying socket flush', () async {
19-
final (client, peer) = await connectTunnel();
20-
final eof = client.inputStream.drain<void>();
21-
// Linux SO_SNDBUF: force the upload to remain pending while reads pause.
22-
client.socket.setRawOption(RawSocketOption.fromInt(1, 7, 4096));
23-
peer.input.pause();
24-
final payload = List<int>.generate(512 * 1024, (i) => i % 251);
25-
client.socket.add(payload);
26-
var flushed = false;
27-
final flushing = client.socket.flush().then((_) => flushed = true);
28-
flushing.ignore();
29-
await Future<void>.delayed(const Duration(milliseconds: 20));
30-
expect(flushed, isFalse, reason: 'The regression needs a pending flush');
31-
await peer.socket.close();
32-
await eof.timeout(_deadline);
33-
peer.input.resume();
34-
await flushing.timeout(_deadline);
35-
await client.close().timeout(_deadline);
36-
await peer.done.future.timeout(_deadline);
37-
expect(peer.bytes.takeBytes(), payload);
38-
}, testOn: 'linux');
39-
4017
for (final tls in [false, true]) {
41-
test('peer EOF drains underlying socket.addStream (TLS=$tls)', () async {
18+
test('peer EOF drains a pending write (TLS=$tls)', () async {
4219
final (client, peer) = await connectTunnel(tls: tls);
4320
final eof = client.inputStream.drain<void>();
44-
final source = StreamController<List<int>>();
45-
addTearDown(source.close);
46-
final uploading = client.socket.addStream(source.stream);
47-
uploading.ignore();
48-
source.add([65]);
49-
await peer.firstChunk.future.timeout(_deadline);
21+
peer.input.pause();
22+
// Exceed the transport buffers without accessing the client's socket.
23+
final payload = 'A' * (8 * 1024 * 1024);
24+
var flushed = false;
25+
final writing = client.write(payload).then((_) => flushed = true);
26+
writing.ignore();
27+
await Future<void>.delayed(const Duration(milliseconds: 200));
28+
expect(flushed, isFalse, reason: 'The regression needs a pending write');
5029
await peer.socket.close();
5130
await eof.timeout(_deadline);
52-
source.add([66]);
53-
await source.close();
54-
await uploading.timeout(_deadline);
31+
peer.input.resume();
32+
await writing.timeout(_deadline);
5533
await client.close().timeout(_deadline);
5634
await peer.done.future.timeout(_deadline);
57-
expect(peer.bytes.takeBytes(), [65, 66]);
35+
expect(peer.bytes.takeBytes(), payload.codeUnits);
5836
});
5937

6038
test('peer EOF drains an accepted outputStream (TLS=$tls)', () async {
@@ -117,24 +95,21 @@ void main() {
11795
expect(peer.bytes.length, 0);
11896
});
11997

120-
test('reset after peer EOF cancels a paused underlying upload source',
121-
() async {
98+
test('reset after peer EOF cancels a paused outputStream source', () async {
12299
final (client, peer) = await connectTunnel();
123100
final eof = client.inputStream.drain<void>();
124101
final source = StreamController<List<int>>();
125102
addTearDown(() async {
126-
client.socket.destroy();
103+
client.destroy();
127104
await source.close();
128105
});
129-
// Shrink SO_SNDBUF to keep the source paused in an in-flight write.
130-
client.socket
131-
.setRawOption(RawSocketOption.fromInt(_solSocket, _soSndbuf, 4096));
132-
final uploading = client.socket.addStream(source.stream);
133-
final failed = expectLater(uploading, throwsA(isA<SocketException>()));
106+
final uploading = client.outputStream.addStream(source.stream);
107+
final failed =
108+
expectLater(uploading, throwsA(isA<SocksConnectionException>()));
134109
source.add([65]);
135110
await peer.firstChunk.future.timeout(_deadline);
136111
peer.input.pause();
137-
source.add(List<int>.filled(512 * 1024, 66));
112+
source.add(List<int>.filled(8 * 1024 * 1024, 66));
138113
await Future<void>.delayed(const Duration(milliseconds: 20));
139114
expect(source.isPaused, isTrue);
140115
await peer.socket.close();
@@ -144,31 +119,29 @@ void main() {
144119
Uint8List.view(Int32List.fromList([1, 0]).buffer)));
145120
peer.socket.destroy();
146121
await failed.timeout(_deadline);
147-
await client.close().timeout(_deadline);
122+
await expectLater(client.close().timeout(_deadline),
123+
throwsA(isA<SocksConnectionException>()));
148124
expect(source.hasListener, isFalse);
149125
await source.close().timeout(_deadline);
150126
}, testOn: 'linux || mac-os');
151127

152-
for (final direct in [false, true]) {
153-
test('close times out when an upload never ends (direct=$direct)',
154-
() async {
155-
final (client, peer) = await connectTunnel(
156-
operationTimeout: const Duration(milliseconds: 100),
157-
);
158-
final eof = client.inputStream.drain<void>();
159-
final source = StreamController<List<int>>();
160-
addTearDown(source.close);
161-
final output = direct ? client.socket : client.outputStream;
162-
output.addStream(source.stream).ignore();
163-
source.add([65]);
164-
await peer.firstChunk.future.timeout(_deadline);
165-
await peer.socket.close();
166-
await eof.timeout(_deadline);
167-
await expectLater(
168-
client.close().timeout(_deadline), throwsA(isA<TimeoutException>()));
169-
await peer.done.future.timeout(_deadline);
170-
expect(client.state, ConnectionState.disconnected);
171-
expect(source.hasListener, isFalse);
172-
});
173-
}
128+
test('close times out when an outputStream upload never ends', () async {
129+
final (client, peer) = await connectTunnel(
130+
operationTimeout: const Duration(milliseconds: 100),
131+
);
132+
final eof = client.inputStream.drain<void>();
133+
final source = StreamController<List<int>>();
134+
addTearDown(source.close);
135+
final output = client.outputStream;
136+
output.addStream(source.stream).ignore();
137+
source.add([65]);
138+
await peer.firstChunk.future.timeout(_deadline);
139+
await peer.socket.close();
140+
await eof.timeout(_deadline);
141+
await expectLater(
142+
client.close().timeout(_deadline), throwsA(isA<TimeoutException>()));
143+
await peer.done.future.timeout(_deadline);
144+
expect(client.state, ConnectionState.disconnected);
145+
expect(source.hasListener, isFalse);
146+
});
174147
}
Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import 'helpers/mock_socks_server.dart';
88
import 'helpers/test_certificates.dart';
99

1010
void main() {
11-
test('legacy plain socket supports a caller-managed TLS upgrade', () async {
11+
test('SOCKSSocket manages TLS with a custom security context', () async {
1212
final certificates = TestCertificates.generate();
1313
final proxy = MockSocksServer()
1414
..sslEnabled = true
@@ -18,16 +18,14 @@ void main() {
1818
final socket = await SOCKSSocket.create(
1919
proxyHost: InternetAddress.loopbackIPv4.address,
2020
proxyPort: proxy.port,
21+
sslEnabled: true,
22+
securityContext: certificates.clientContext(),
2123
);
2224
addTearDown(socket.close);
2325
await socket.connect();
2426
await socket.connectTo('localhost', 443);
25-
final secured = await SecureSocket.secure(socket.socket,
26-
host: 'localhost', context: certificates.clientContext());
27-
addTearDown(secured.destroy);
28-
final reply = secured.expand((b) => b).take(10).toList();
29-
secured.add(utf8.encode('manual TLS'));
30-
await secured.flush();
31-
expect(utf8.decode(await reply), 'manual TLS');
27+
final reply = socket.inputStream.expand((b) => b).take(10).toList();
28+
await socket.write('custom TLS');
29+
expect(utf8.decode(await reply), 'custom TLS');
3230
});
3331
}

0 commit comments

Comments
 (0)