Summary
When the remote server half-closes the tunnel (sends FIN but keeps reading), SOCKSSocket treats that as a full close: _peerClosed() calls close(), which closes outputStream. A write that is already in progress is still drained, but any write started afterwards fails with StateError: Output sink is closed, and nothing reaches the server.
TCP allows writing after receiving FIN, and a server that half-closes is still reading, so these writes should be delivered.
Reproduced on socks_socket 1.4.0 (pub.dev) and on master at 3ad7d76, on macOS with Dart 3.12.2. Builds without 3910f19 delivered the write.
Steps to reproduce
import 'dart:async';
import 'dart:io';
import 'package:socks_socket/socks_socket.dart';
import 'package:test/test.dart';
void main() {
test('writes after the server half-closes are rejected', () async {
// Minimal SOCKS5 proxy: answers CONNECT, then half-closes the tunnel
// (stops sending) but keeps reading.
final proxy = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
var tunnelBytes = 0;
final done = Completer<void>();
proxy.listen((peer) {
final buffer = <int>[];
var tunnel = false;
peer.listen((bytes) {
if (tunnel) {
tunnelBytes += bytes.length;
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]);
peer.close(); // half-close: FIN to the client, keep reading
}
}, onDone: done.complete, onError: (Object _) {});
});
final socket = await SOCKSSocket.create(
proxyHost: InternetAddress.loopbackIPv4.address, proxyPort: proxy.port);
await socket.connect();
await socket.connectTo('example.com', 80);
// Listening is what lets the client notice the server's EOF.
final eof = Completer<void>();
socket.inputStream.listen(null, onDone: eof.complete);
await eof.future;
Object? result;
try {
await socket.outputStream.addStream(Stream.value([1, 2, 3]));
result = 'delivered';
} catch (e) {
result = e;
}
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {});
print('write after server EOF: $result; proxy received $tunnelBytes bytes');
expect(result, 'delivered');
await proxy.close();
});
}
Expected
write after server EOF: delivered; proxy received 3 bytes
Actual
write after server EOF: Bad state: Output sink is closed; proxy received 0 bytes
Cause
In lib/socks_socket.dart (master), the input subscription's onDone calls _peerClosed(), which sets _peerReadClosed, marks the socket disconnected, and calls close(). close() then closes the output sink and stops accepting writes. 818072d keeps an already accepted addStream running after peer EOF, but nothing new can be written.
Impact
Callers that queue writes and hand them to outputStream one at a time lose every queued write the moment the server half-closes, even though the server is still reading. fusiondart does exactly this, and pins the current behaviour in a test until this is fixed (cypherstack/fusiondart#29, test "a send still queued when the server closes its side fails").
Suggested fix
On peer EOF, close only the input side: close inputStream, keep outputStream open, and leave the full close to the application's own close() call, or to a write failure. Two options if auto-closing is intentional for some callers:
- Make it opt-in, for example
closeOnPeerEof: true.
- Add a binary write that returns a future per write (for example
Future<void> writeBytes(List<int>)) and is allowed after peer EOF. Today only the string-based write() returns one.
Summary
When the remote server half-closes the tunnel (sends FIN but keeps reading),
SOCKSSockettreats that as a full close:_peerClosed()callsclose(), which closesoutputStream. A write that is already in progress is still drained, but any write started afterwards fails withStateError: Output sink is closed, and nothing reaches the server.TCP allows writing after receiving FIN, and a server that half-closes is still reading, so these writes should be delivered.
Reproduced on socks_socket 1.4.0 (pub.dev) and on
masterat 3ad7d76, on macOS with Dart 3.12.2. Builds without 3910f19 delivered the write.Steps to reproduce
Expected
write after server EOF: delivered; proxy received 3 bytesActual
write after server EOF: Bad state: Output sink is closed; proxy received 0 bytesCause
In
lib/socks_socket.dart(master), the input subscription'sonDonecalls_peerClosed(), which sets_peerReadClosed, marks the socket disconnected, and callsclose().close()then closes the output sink and stops accepting writes. 818072d keeps an already acceptedaddStreamrunning after peer EOF, but nothing new can be written.Impact
Callers that queue writes and hand them to
outputStreamone at a time lose every queued write the moment the server half-closes, even though the server is still reading. fusiondart does exactly this, and pins the current behaviour in a test until this is fixed (cypherstack/fusiondart#29, test "a send still queued when the server closes its side fails").Suggested fix
On peer EOF, close only the input side: close
inputStream, keepoutputStreamopen, and leave the full close to the application's ownclose()call, or to a write failure. Two options if auto-closing is intentional for some callers:closeOnPeerEof: true.Future<void> writeBytes(List<int>)) and is allowed after peer EOF. Today only the string-basedwrite()returns one.