4 min read

Half-Duplex Streaming

This article describes a technical solution to TCP socket communication in half-duplex mode without an explicit response termination protocol.

Problem

TCP connections allow communication in both directions simultaneously. Octets stream out and in, once connected. They become network datagrams and fly magically over the wire and through the air wirelessly arriving and reassembling as streams at the other end of the socket ready for consumption. Output at one end becomes input at the other and vice versa at the same time.

The octet streams pass through many buffers and many protocol layers. At the “user” level, that of the producers and consumers of octet streams, additional layers of protocol apply in order to encode and decode the two-way streams. Higher level protocols vary from very simple to highly complex, e.g. SMTP, HTTP, HTTPS.

Keep it simple.

Half Duplex

What is “duplex?” Two-way at the same time. Half of that gives two-way but not simultaneously.

Imagine the simplest of client-server socket-level protocols. One side acts as a server; the other acts as the client. The client connects to the server and the server accepts connections. Once set up, the client sends a message and waits for the reply for a limited number of seconds. Call this a request-response cycle.

Implicitly, time itself behaves as the semantic terminator or, to be more technical about it, the buffer flushing events trigger the switch between requests and responses in each cycle. Typically this is not the full story. Buffering overlaps some stream data-oriented signalling in most scenarios, e.g. a new-line terminator, so that the server sees all pending octets correctly formatted and terminated in some protocol-prescribed way.

For the sake of pure simplicity, assume here that termination is not guaranteed, either not present or absent under certain conditions such as errors.

Solution

See the sequence diagram below.

Prolog Predicate

Prolog presents a useful demonstration of the principles since it expresses the solution in pure logic.

%!  half_duplex(+StreamPair, +Term, -Codes, +TimeOut) is semidet.
%!  half_duplex(+In, -Codes, +TimeOut) is semidet.
%
%   Performs a single half-duplex stream   interaction  with StreamPair.
%   Flushes Term to the output  stream.   Reads  pending  Codes from the
%   input stream within TimeOut  seconds.   Succeeds  when  a write-read
%   cycle completes without timing out; fails on time-out expiry.
%
%   Filling a stream buffer blocks the  calling   thread  if there is no
%   input ready. Pending read operations also block for the same reason.
%   Hence the wait_for_input/3 *must* precede them.
%
%   @arg StreamPair connection from client to server, a
%   closely-associated input and output stream pairing used for
%   half-duplex communication.
%
%   @arg Term to write and flush.
%
%   @arg Codes waited for and extracted from the pending input stream.
%
%   @arg TimeOut in seconds.

half_duplex(StreamPair, Term, Codes, TimeOut) :-
    stream_pair(StreamPair, In, Out),
    write(Out, Term),
    flush_output(Out),
    half_duplex(In, Codes, TimeOut).

half_duplex(In, Codes, TimeOut) :-
    wait_for_input([In], [Ready], TimeOut),
    fill_buffer(Ready),
    read_pending_codes(Ready, Codes, []).

Read the logic this way: there exists a logical relation between a stream pair, some output term and some input codes constrained by a time-out in seconds. The relation implies a write and a flush followed by a timed wait for results. The latter appears as the second predicate, of three arguments because the output half the cycle no longer matters. The timed wait succeeds if and only if the input side of the stream pair becomes ready within the prescribed time-out period; after which the ready stream fills its buffer from the incoming socket connection and reads all the pending codes until empty.

Explanation

The 3-arity half_duplex/3 predicate fails if and when wait_for_input/3 fails to unify Ready. Terms Ready and In unify at the same ready-input stream; they can bind the same variable if preferred. They bind to different variables in the above logic for two reasons: first, to emphasise the distinction between the input stream in its abstract, stateless form versus its ready state; and secondly, the ‘fill and read’ only cares about some ready stream and remain careless about where that stream originates.

Reading pending codes with [] as the tail continues reading until no codes remain. It reads the buffer only and does not block the thread.

Weaknesses

The predicate deliberately assumes that the input stream has an empty buffer upfront. The logic does not attempt to pre-fill and then discard that buffer, by design. That would require another period of pending time so that the operating system’s buffer fills. The implementation presented assumes that the previous half-duplex cycle performs the necessary request-response time-out and therefore the buffer has no pending input.

The same approach applies to any bi-directional stream connection, TCP, USB or even UART. The technique remains ignorant of the underlying mechanisms. The interface only needs (1) to fill the incoming buffer while waiting, effectively the opposite of flushing the output, and (2) has the software functional capability to read all pending octets without thread blocking.