Repository navigation
outputStream.addStream() reports success when the transport is destroyed mid-write #2
Description
Summary
If the underlying socket is destroyed while a write through SOCKSSocket.outputStream is still in progress, the addStream() future completes normally instead of failing. The data is never delivered, so the caller is told the write succeeded when it didn't.
Reproduced on socks_socket 1.4.0 (pub.dev) and on master at 3ad7d76, on macOS with Dart 3.12.2.
Steps to reproduce
- Connect through a SOCKS5 proxy whose tunnel peer stops reading, so a large write stays in flight.
- Start
outputStream.addStream()with a frame bigger than the socket buffers (8 MiB). - Call
socket.socket.destroy()while that write is pending.
import 'dart:async';
import 'dart:io';
import 'package:socks_socket/socks_socket.dart';
import 'package:test/test.dart';
void main() {
test('write cut short by destroy() reports success', () async {
// Minimal SOCKS5 proxy: accepts CONNECT, then never reads tunnel data.
final proxy = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
proxy.listen((peer) {
final buffer = <int>[];
var tunnel = false;
late StreamSubscription<List<int>> input;
input = peer.listen((bytes) {
if (tunnel) return;
buffer.addAll(bytes);
if (buffer.length == 3) {
peer.add([5, 0]);
} else if (buffer.length > 8 && buffer.length == 10 + buffer[7]) {
tunnel = true;
peer.add([5, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
input.pause();
}
}, onError: (Object _) {});
});
final socket = await SOCKSSocket.create(
proxyHost: InternetAddress.loopbackIPv4.address, proxyPort: proxy.port);
await socket.connect();
await socket.connectTo('example.com', 80);
// 8 MiB can't fit in the socket buffers, so this write stays in flight.
final write = socket.outputStream
.addStream(Stream.value(List.filled(8 * 1024 * 1024, 1)));
await Future<void>.delayed(const Duration(milliseconds: 200));
socket.socket.destroy();
// Expected: the write fails. Actual: it completes normally.
await expectLater(write, throwsA(anything));
await socket.close().catchError((Object _) {});
await proxy.close();
});
}Expected
The pending addStream() future fails, for example with a SocketException or SocksConnectionException, because the frame was not delivered.
Actual
Expected: throws anything
Actual: <Instance of 'Future<dynamic>'>
Which: emitted <null>
The future completes about 60 ms after destroy(), and the peer received 0 bytes of the frame.
Impact
Callers that abort a stalled connection, for example after their own send timeout, can't tell whether a write was delivered. fusiondart works around this by treating any write that finishes after it has aborted the connection as failed (cypherstack/fusiondart#29, Connection._socks). That workaround can be removed once this is fixed.
Notes
- Not yet root-caused. The write goes through
_queueWrite:socket.add()thensocket.flush()on theRawChannel-backed_ConnectionSocket. AfterRawChannel.destroy(), that flush appears to complete normally rather than with the destroy error. - Only tested on the plain (non-TLS) path.
Activity
feat: add destroy() that fails writes it cuts short
feat: add destroy() that fails writes it cuts short
feat: add destroy() that fails writes it cuts short
feat: add destroy() that fails writes it cuts short
feat: add destroy() that fails writes it cuts short