Skip to content

outputStream.addStream() reports success when the transport is destroyed mid-write #2

Closed

Description

@sneurlax

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

  1. Connect through a SOCKS5 proxy whose tunnel peer stops reading, so a large write stays in flight.
  2. Start outputStream.addStream() with a frame bigger than the socket buffers (8 MiB).
  3. 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() then socket.flush() on the RawChannel-backed _ConnectionSocket. After RawChannel.destroy(), that flush appears to complete normally rather than with the destroy error.
  • Only tested on the plain (non-TLS) path.

Activity

  1. added 5 commits that reference this issue on Oct 7, 2026
    c3921dc
    b28b28c
    b7f7d6a
    8f78dde
    53eca8d
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