fix(peer): send fatal-error answers before the connection closes
Summary
When a request arrived with a fatal message error (an unsupported version, or a Message Length shorter than the header or not a multiple of 4), Manager queued the error answer and returned to the Server reader. The reader then closed the transport, usually before the session writer had sent the answer. For a fatal error, Manager now returns only once that answer has been written or has failed, the session has ended, or Timers.Closing has elapsed since the decision. The answer therefore reaches the peer before the connection closes. The MessageErrorHandler contract now states this rule.
Closes #147.
Behaviour
Answers delivered before EOF, Manager accepted and dialed:
| Case | Before | After |
|---|---|---|
| Open over TCP: 5011, 5015 for a short length, 5015 for an unaligned length (24 runs each) | 0–3 of 24 | 24 of 24 |
| Accepted, CER with the same header errors | 0 of 72 | 72 of 72 |
| Open over TLS (12 runs per case) | 1–5 of 12 | 12 of 12 |
| Open over SCTP (12 runs per case) | 0–4 of 12 | 12 of 12 |
| Direct fatal-error call on a valid header, 5014 (24 runs each) | 0–1 of 24 | 24 of 24 |
| 800 established TCP connections | 248 answered | 800 answered, no reply_failure |
Unchanged:
- StateMachine and Client, which already answered.
- A dialed connection waiting for CEA, where RFC 6733 §5.6 selects no answer.
- A truncated body, which ends in EOF rather than a message error.
- Non-fatal errors.
Testing
- New public tests: TCP, TLS and SCTP through Manager accepted and dialed, with StateMachine and Client as the parity reference. SCTP runs in Linux containers.
- Deterministic
testing/synctesttests: every way the wait ends (written, failed, short write, skipped, terminal disposal, session end, the Closing bound), plus a peer that stops reading. - Mutation check: removing the wait makes every new public test family fail.
- Packet captures: over TCP and SCTP, accepted and dialed, the answer leaves before FIN or SCTP SHUTDOWN.
- Allocations:
BenchmarkInboundMessagesin peer and sm shows identical B/op and allocs/op to main in all 14 cases. - Local validation:
- every module on Go 1.27.2 and 1.26.9;
- the race suite, with 20 repetitions of the new tests on both toolchains;
- vet, staticcheck, golangci-lint, govulncheck and the secret scan;
- the Windows build and CodeQL;
- apidiff: no API change in 48 packages;
- all 13 SCTP wire-harness variants.
- Also fixed:
TestAcceptedRestartFreshHandshakenow checks the replacement's admission after its DWR/DWA exchange. The handshake notification it waited on can arrive before admission is released, which made it fail intermittently under the race detector.
Summary by cubic
Fixes fatal-error answers being lost when the connection closes. Manager now waits for the error answer before the Server closes the transport, so peers get the required 5011/5015/5014 answers instead of a dropped connection.
Previously Manager queued the answer and returned, letting the reader close the connection first — on 800 established TCP connections, 552 received no answer. Now HandleMessageError returns only after the answer is written, fails, the session ends, or Timers.Closing elapses since the decision, so a peer that stops reading can't hold the reader past that bound. Non-fatal errors and decisions without an answer don't wait.
Contract
MessageErrorHandlerhandlers must finish any fatal-error answer before returning; documented indiam/server.go.
Also fixed
TestAcceptedRestartFreshHandshakechecks the replacement's admission after its DWR/DWA exchange, since the handshake notification can arrive before admission is released.- New tests cover TCP, TLS, and SCTP, accepted and dialed, including mutation and allocation checks.
Closes #147.