Every read in the mORMot2 transport took a timeout parameter, and none of them honoured it. Here is why a Receive(1000) could wait forever, and what changed.

Affects: mORMot2 comm adapter only

Unaffected: Indy, Synapse

Products: all Habari Client libraries (for ActiveMQ · Artemis · OpenMQ · RabbitMQ)

Verified on: Delphi 2009, FPC 3.2.2

The symptom

Select the mORMot2 communication adapter, subscribe to a queue nobody is publishing to, and ask for a message with a one second timeout:

Msg := Consumer.Receive(1000);   // expected: nil after 1000 ms

The call never came back. Not after a second, not after a minute. The same held for (Connection as IHeartbeat).ReceiveHeartbeat(500) against a server that had gone quiet. Nothing was broken at the socket level — the client was simply waiting, and no argument we passed could persuade it to stop.

02Two different clocks

The adapter set ReceiveTimeout before each read and trusted it. But that property only writes the SO_RCVTIMEO socket option, while mORMot’s own read loops measure their deadline with something else entirely: the TimeOut field handed to WaitFor. Setting one does not bound the other.

What becomes of the expiry is the interesting part:

  1. ReceiveTimeout := ATimeOut sets SO_RCVTIMEO on the socket.
  2. recv() duly returns WSAETIMEDOUT when that time is up.
  3. NetErrorFromSystem maps WSAETIMEDOUT onto nrRetry — the same bucket as “would block”.
  4. TrySockRecv reads nrRetry as success-with-no-data: res := nrOk; read := 0, and the next test sends it straight back to recv().

The branch that would have reported nrTimeout sits behind WaitFor(TimeOut, ...), and that continue steps around it. So the expiry is laundered into “try again”, and the loop ends only when data arrives or the peer hangs up. SockRecvLn wraps this in a second retry loop of its own, equally unbounded. The old code looked entirely reasonable:

TCPClient.ReceiveTimeout := ATimeOut;   // sets SO_RCVTIMEO, nothing more
TCPClient.SockRecvLn(L);                // waits until data arrives. Full stop.

Error mapping confirmed on Windows; the translation table is per platform.

03The fix: ask before you read

SockReceivePending is the one call in the family that does respect a deadline. It goes straight to WaitFor — select or poll — and returns cspNoData when the time runs out. Gate every read on it, and a read only ever runs when a byte is already waiting, so it cannot block:

function TTCPClient.HasDataWithin(ATimeOut: Integer): Boolean;
begin
  Result := SockReceivePending(ATimeOut) in
    [cspDataAvailable, cspDataAvailableOnClosedSocket];
end;

function TTCPClient.ReadByteWithin(ATimeOut: Integer): Byte;
var
  B: Byte;
begin
  if not HasDataWithin(ATimeOut) then
    raise EBTStompError.CreateFmt(
      'Timed out after %d ms while reading a frame', [ATimeOut]);

  // at least one byte is pending, so this does not block
  SockRecv(@B, 1);
  Result := B;
end;

A small set of helpers on TTCPClient — one per shape of read the STOMP codec needs — now sits between the adapter and the socket. Every read path in the unit goes through one of them:

Read pathWasNow bounded by
TryConsumeHeartBeatsSockRecvLnReadLine
ReadHeaderLineSockRecvLnReadLine
ReadMessageBodyWithLengthSockRecvReadBufferWithin
ReadMessageBodyTerminatedSockRecv per byteReceiveTerminated
ReadTrailingNull / LfSockRecvReadByteWithin

04A line already in flight is not a timeout

Waiting for a frame that may never come and waiting for the rest of a frame already arriving are different situations, and one value cannot serve both. ReadLine therefore takes two timeouts. The caller’s timeout applies while waiting for the first byte; once a line has started, the remaining bytes belong to a frame already on the wire, so the data timeout applies to them, and running out there is a protocol error rather than a quiet “nothing arrived”.

Why the distinction matters

An empty line from ReadLine is ambiguous by nature: it may be the blank line that ends the headers, or a heart-beat, or nothing at all. An out parameter tells them apart — and it is what ReadLnTimedOut now reports. That function previously returned a hard-coded False under a // not implemented! comment, leaving the frame reader unable to distinguish silence from an empty line.

05Proof, not confidence

The stub server in the test suite already spoke STOMP over a real socket; it gained a switch to stop sending heart-beats, which is all it takes to force a read timeout. Two tests came with it — one asserting that ReceiveHeartbeat returns False instead of blocking, one asserting the adapter still works after a timeout rather than being left wedged. Both run once per communication adapter.

AdapterBeforeAfterReturned within
TBTCommAdapterIndy6 / 66 / 6500 ms
TBTCommAdapterSynapse6 / 66 / 6515 ms
TBTCommAdaptermORMot24 / 6 — blocked6 / 6bounded

Indy and Synapse were never affected: both branch on an explicit timed-out flag their socket libraries provide. They appear in the table because a fix that quietly breaks the two working transports is not a fix.

Availability. BTCommAdapterBasemORMot2.pas is a shared unit, so the change reaches all four Habari client libraries — ActiveMQ, Artemis, OpenMQ and RabbitMQ — from the same source.

Do you need it? Only if you select the mORMot2 adapter and rely on a read timeout: Receive with a timeout, ReceiveHeartbeat, or any receive against a broker that may fall silent. Applications on Indy or Synapse are unchanged.