Repository navigation
sm: set the peer context before the CEA; diam: close the connection on a failed write - #274
Conversation
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.
#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.
|
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. |
Two small fixes found while running a credit-control server on v4.5.x with
MaxConcurrentHandlersand 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.MaxConcurrentHandlersset, every message is handled on its own goroutine, CER included.handleCERwrote 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 reachhandshakeOKbefore the CER goroutine reachesSetContext, andhandshakeOKdrops 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.MaxConcurrentHandlers=8MaxConcurrentHandlers=8, with a 50 ms sleep inserted between the CEA write andSetContextAfter the fix, the first run shows 0 / 3,000 under the same load. Present since
MaxConcurrentHandlerswas added in v4.2.0.Fix:
handleCERsets the context beforesuccessCEAwrites 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. TheOnCEAhook runs immediately before the CEA write, so a hook that checkssmpeer.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.dispatchwould fix that too; I've left it out of this PR because it changes dispatch semantics indiam, and can propose it separately if you want it.2. A failed write leaves the connection open with a writer that never recovers
Conn.Writegoes through abufio.Writer, which keeps returning its first error from every laterWriteandFlush(crypto/tlsbehaves the same).writeLockedonly returned the error. So after one write that hitServer.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:
writeLockedcloses 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,CloseNotifyfires, 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 insm/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
WriteTimeoutwill see a disconnect where they previously saw silent failure.Test:
TestWriteErrorClosesConnfloods a non-reading peer until a write times out and asserts the peer then reads EOF.