Repository navigation
SOCKSSocket closes its output sink when the server half-closes, rejecting later writes #3
Description
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-basedwrite()returns one.
Activity
feat: add closeOnPeerEof to keep writing after a peer half-close
feat: add closeOnPeerEof to keep writing after a peer half-close
feat: add closeOnPeerEof to keep writing after a peer half-close
feat: add closeOnPeerEof to keep writing after a peer half-close
feat: add closeOnPeerEof to keep writing after a peer half-close
sneurlax commentedon Oct 7, 2026
2.0.0 (PR #4) adds
closeOnPeerEoftoSOCKSSocket.create(). Passfalseto keep writing after the server half-closes, until you callclose(). The default is unchanged to avoid altering existing callers, so this is opt-in rather than a fix of the default behaviour. fusiondart's 2.0.0 branch already passescloseOnPeerEof: false.