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:
ReceiveTimeout := ATimeOutsetsSO_RCVTIMEOon the socket.recv()duly returnsWSAETIMEDOUTwhen that time is up.NetErrorFromSystemmapsWSAETIMEDOUTontonrRetry— the same bucket as “would block”.TrySockRecvreadsnrRetryas success-with-no-data:res := nrOk; read := 0, and the next test sends it straight back torecv().
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 path | Was | Now bounded by |
|---|---|---|
| TryConsumeHeartBeats | SockRecvLn | ReadLine |
| ReadHeaderLine | SockRecvLn | ReadLine |
| ReadMessageBodyWithLength | SockRecv | ReadBufferWithin |
| ReadMessageBodyTerminated | SockRecv per byte | ReceiveTerminated |
| ReadTrailingNull / Lf | SockRecv | ReadByteWithin |
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.
| Adapter | Before | After | Returned within |
|---|---|---|---|
| TBTCommAdapterIndy | 6 / 6 | 6 / 6 | 500 ms |
| TBTCommAdapterSynapse | 6 / 6 | 6 / 6 | 515 ms |
| TBTCommAdaptermORMot2 | 4 / 6 — blocked | 6 / 6 | bounded |
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.