Skip to content

SOCKSSocket closes its output sink when the server half-closes, rejecting later writes #3

Description

@sneurlax

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.

Activity

  1. added 5 commits that reference this issue on Oct 7, 2026
    7558418
    7699736
    66e0cd6
    fca0343
    4dffbfb
  2. sneurlax commented on Oct 7, 2026

    @sneurlax
    MemberAuthor

    2.0.0 (PR #4) adds closeOnPeerEof to SOCKSSocket.create(). Pass false to keep writing after the server half-closes, until you call close(). 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 passes closeOnPeerEof: false.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions