Skip to content

TCP Client cannot send after peer half-closes its write side #171

Description

@kentbull

Problem

Client.cutoff currently represents both receive EOF and connection-wide terminality. When the peer half-closes its write direction, Client.receive() correctly observes EOF and sets cutoff:

if data:
    ...
else:
    self.cutoff = True

However, Client.serviceSends() also uses cutoff to disable output:

while self.txbs and self.connected and not self.cutoff:
    count = self.send(self.txbs)
    del self.txbs[:count]

TCP EOF is directional. A peer calling shutdown(SHUT_WR) means no more bytes can arrive from that peer; it does not prevent the Client from sending a response through the opposite direction.

The current behavior therefore strands output queued after receive EOF in Client.txbs.

Reproduction

  • Connect a Client to one side of a socket pair.
  • Have the peer call shutdown(socket.SHUT_WR).
  • Service Client receives and observe EOF.
  • Queue a response after EOF.
  • Service Client sends.
  • Observe that the response remains in txbs and never reaches the peer.

This test fails on current main at assert not client.txbs:

import socket

from hio.core import tcp


def test_client_sends_after_real_peer_half_close():
    cs, peer = socket.socketpair()
    peer.settimeout(1.0)

    client = tcp.Client(ha=("127.0.0.1", 6101))
    client.cs = cs
    client.accepted = True

    try:
        peer.shutdown(socket.SHUT_WR)
        client.serviceReceives()

        assert client.cutoff is True

        response = b"response after peer EOF"
        client.tx(response)
        client.serviceSends()

        assert not client.txbs
        assert peer.recv(len(response)) == response
    finally:
        client.close()
        peer.close()

Expected behavior

Receiving clean peer EOF should close only the Client receive direction. The Client should remain able to accept and transmit output until its send direction is independently closed or fails.

Proposed solution

Track receive and transmit terminal state independently. One implementation uses cutoff for receive closure and adds a separate transmit-closure state:

self.cutoff = False
self.txCutoff = False
self.error = None

Receive EOF changes only the receive state:

if not data:
    self.cutoff = True

Send service is governed by the transmit state:

while self.txbs and self.connected and not self.txCutoff:
    count = self.send(self.txbs)
    del self.txbs[:count]
    break

A terminal send failure closes only the transmit direction where appropriate and preserves the failure and unsent bytes:

except BrokenPipeError as ex:
    self.txCutoff = True
    self.error = ex
    count = 0

The exact state representation need not be named txCutoff, but it must preserve the directional invariant: receive EOF alone must not disable sending.

Acceptance criteria

  • Peer SHUT_WR marks the Client receive direction closed without marking transmit closed.
  • Output queued before or after receive EOF can still drain.
  • The peer receives that output over a real socket.
  • Terminal send failure preserves unsent bytes and is represented independently from receive EOF.
  • Allocating a new connection resets both directional terminal states.

Activity

  1. kentbull commented on Aug 28, 2026

    @kentbull
    ContributorAuthor

    Downstream implementation: GLEIF-IT#9.

    GLEIF-IT#16 adds real-socket coverage demonstrating that a Client can send output queued after peer SHUT_WR.

    These changes must be adapted to current upstream main; they are not a clean cherry-pick.

  2. changed the title [-]TCP Client stops sending after receiving peer EOF[/-] [+]TCP Client cannot send after peer half-closes its write side[/+] on Aug 29, 2026
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