Skip to content

sm: set the peer context before the CEA; diam: close the connection on a failed write - #274

Merged
fiorix merged 2 commits into
fiorix:mainfrom
kulcsartibor:main
Oct 31, 2026
Merged

fiorix merged 2 commits into
fiorix:mainfrom
kulcsartibor:main

Conversation

@kulcsartibor

Copy link
Copy Markdown
Contributor

Two small fixes found while running a credit-control server on v4.5.x with MaxConcurrentHandlers and a DRA in front of it. One commit each, each with a regression test that fails on current main. About ten lines of non-test code in total; no new API.

1. The peer's first request after the CEA can be dropped under concurrent dispatch

With Server.MaxConcurrentHandlers set, every message is handled on its own goroutine, CER included. handleCER wrote the CEA first and stored the peer metadata on the connection afterwards (c.SetContext). A peer that sends its first request as soon as it reads the CEA can have that request reach handshakeOK before the CER goroutine reaches SetContext, and handshakeOK drops it silently: no answer, no error report. The peer sees a Tx timeout on its first request.

Reproduced on the wire (v4.5.1): 3,000 handshakes each followed immediately by a CCR, GOMAXPROCS=2, CPU load from four busy loops.

Dispatch Unanswered first requests
MaxConcurrentHandlers=8 2 / 3,000 (the CCR handler ran 2,998 times)
MaxConcurrentHandlers=8, with a 50 ms sleep inserted between the CEA write and SetContext 20 / 20
sequential (0) 0 / 3,000

After the fix, the first run shows 0 / 3,000 under the same load. Present since MaxConcurrentHandlers was added in v4.2.0.

Fix: handleCER sets the context before successCEA writes the CEA. A request can only arrive after the peer has read the CEA, and by then the context is set. A failed CEA write now closes the connection; previously it was reported and the connection left open.

Test: TestHandleCER_PeerContextSetBeforeCEA. The OnCEA hook runs immediately before the CEA write, so a hook that checks smpeer.FromContext(c.Context()) observes the order deterministically, with no sleeps or load.

Not covered: two CERs dispatched concurrently (a retransmission) can both get a CEA. Handling CER/DWR/DPR inline in conn.dispatch would fix that too; I've left it out of this PR because it changes dispatch semantics in diam, and can propose it separately if you want it.

2. A failed write leaves the connection open with a writer that never recovers

Conn.Write goes through a bufio.Writer, which keeps returning its first error from every later Write and Flush (crypto/tls behaves the same). writeLocked only returned the error. So after one write that hit Server.WriteTimeout — typically a DRA that stopped reading for a while — the connection stayed open: the read loop kept reading, handlers kept processing requests, and every answer, DWA and server-initiated request failed, until the peer's watchdog gave up.

Reproduced (v4.5.1): a handler floods a non-reading peer until a 200 ms write timeout, the peer drains everything, the handler writes one small message: still i/o timeout. The peer's next read then timed out rather than returning EOF, so the server still held the connection open.

Fix: writeLocked closes the underlying connection on any write error, on both the buffered TCP path and the SCTP path. The read loop then exits on its read error, serve's deferred close is idempotent, CloseNotify fires, and the peer can fail over. Resetting the writer would not do, because a partial frame may already be on the wire. The client role already closes on its own failed writes in sm/client.go; this covers the server's own CEA/DWA writes and every application write.

Behaviour change: a write that times out now ends the connection instead of leaving it open but unusable. Anyone running a very low WriteTimeout will see a disconnect where they previously saw silent failure.

Test: TestWriteErrorClosesConn floods a non-reading peer until a write times out and asserts the peer then reads EOF.

handleCER wrote the CEA and only then stored the peer metadata on the
connection. With Server.MaxConcurrentHandlers set, every message is
handled on its own goroutine, so the peer's first request after the CEA
could reach handshakeOK before SetContext ran and be dropped with no
answer and no error. Reproduced on the wire: 2 of 3,000 first requests
after the CEA were unanswered with MaxConcurrentHandlers=8 under CPU
load, and 20 of 20 with the window widened artificially; 0 with
sequential dispatch.

The context is now set before successCEA writes the CEA, so no request
the peer can send after reading the CEA finds it unset. A failed CEA
write now closes the connection instead of leaving it open and marked as
handshaked.

TestHandleCER_PeerContextSetBeforeCEA observes the order through the
OnCEA hook, which runs immediately before the CEA write.
Conn.Write goes through a bufio.Writer, which keeps returning its first
error from every later Write and Flush. writeLocked only returned the
error, so after one write timeout the connection stayed open: the read
loop kept reading and handlers kept processing requests whose answers
could never be sent, including the state machine's own CEAs and DWAs,
until the peer's watchdog gave up. Reproduced: after a 200 ms write
timeout against a non-reading peer, a later write to the same connection
failed with the same error once the peer was reading again, and the
connection was still open.

writeLocked now closes the underlying connection on any write error.
The read loop then exits, CloseNotify fires, and the peer can fail over.
Resetting the writer would not do: a partial frame may already be on
the wire. The client role already closes on its own failed writes
(sm/client.go); this covers the server's writes and every application
write.

This changes what Server.WriteTimeout does: a timed-out write now ends
the connection instead of leaving it open but unusable.

TestWriteErrorClosesConn floods a non-reading peer until a write times
out and checks that the peer then reads EOF.
@fiorix
fiorix merged commit e537bbc into fiorix:main Oct 31, 2026
1 check passed
fiorix added a commit that referenced this pull request Oct 31, 2026
#274 made writeLocked close the connection on any write error. That is
right for TCP and TLS: the bufio.Writer keeps returning its first error
and a partial frame may already be on the wire, so the connection cannot
be used again.

It is wrong for SCTP. Each send carries a whole message, so a failed
send leaves nothing partial behind, and some errors only affect that
message: EMSGSIZE for a message the association cannot carry, or EINVAL
for an invalid stream. Closing on those tore down the association and
every request in flight on it. When the association itself is gone, the
read loop sees the error and closes the connection as before.

The close only ever applied to direct Conn.Write calls on SCTP anyway:
Message.WriteTo goes through response.WriteStream, which writes to the
association without reaching writeLocked. The multistream path now
returns the error without closing, as it did before #274; TCP and TLS
keep the close.

TestWriteErrorKeepsMultistreamConn writes through a MultistreamConn
whose Write fails and checks that the connection is not closed.
@fiorix

fiorix commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thanks, Tibor. Both fixes are merged as two commits, and the reproductions and the deterministic OnCEA test made them easy to review.

Remainder, found by the agent:

One follow-up on main: writeLocked no longer closes the connection on an SCTP write error. For TCP and TLS the close is right for the reason you gave, since bufio keeps returning its first error and a partial frame may be on the wire. SCTP sends whole messages, so a failed send leaves nothing partial, and errors such as EMSGSIZE or an invalid stream only affect that message. Closing on those would drop the association and everything in flight on it. Message.WriteTo on SCTP goes through WriteStream and never reached that path anyway, and a dead association still fails the reads and closes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants