Repository navigation
fix: give the client one connection it owns and let Close end a request - #63
Merged
Merged
Conversation
The engine harness's in-memory transport becomes safe for concurrent use: a deadline set or a Close from another goroutine wakes a waiting read, and after Close reads, writes and deadlines fail with net.ErrClosed, as on a socket. The transcripts of the existing scenarios are unchanged. New pins of today's behavior, known bugs included: a request after Close fails as if the client never had a connection; a Close from a hook during an attempt or between attempts makes the request dereference a nil Conn; a connection closed from another goroutine ends the request with net.ErrClosed; the Handler's Close panics without a connection, leaves Conn set and closes it again; a second Connect leaves the first connection open, and a failed dial drops it without closing it; the TCP reconnect leaves the connection the agent closed open, a refused reconnect leaves the client without a connection, and a hook that changes Port and Control before the agent closes the connection redirects the reconnect. The TCP reconnect tests share one loopback agent helper.
Connect and the TCP reconnect wrote Conn before knowing whether the dial worked and closed nothing they replaced: a second Connect leaked the first connection, a failed dial dropped the old one without closing it, a failed unconnected UDP listen left a nil *net.UDPConn in Conn, and the reconnect leaked the connection the agent closed and dialed with the client's Target, Port, LocalAddr and Control at that moment, so a hook redirected the running request. Requests read Conn without the lock Close takes: a Close from another goroutine was a data race, and a request after it dereferenced a nil Conn. The Handler's Close closed Conn itself, panicking without one and closing it again on a second call. A cancelled Context was noticed only when the attempt's deadline passed. The client now keeps a record of the connection it opened: the net.Conn, the settings that open it again, and the agent's address of an unconnected socket. Connect and the reconnect open a connection in locals and replace the client's only when it opened, closing the old one; a failed Connect or reconnect leaves the client as it was. The reconnect reopens the attempt's connection with the settings it was opened with (a Conn the caller set: with the request's configuration), within the request's timeout and Context, and only while it is still the client's. Close, Connect and the reconnect change the connection under the client's lock, and each attempt takes it under the lock. Close is safe from any goroutine at any time: it closes the connection, which ends a running request's read or write, and later requests fail with an error wrapping net.ErrClosed until Connect opens a connection or Conn is set. The Handler's Close is the client's. A Context that can be cancelled sets a deadline in the past on the attempt's connection when it is done, so its cancellation ends the attempt at once; the attempt waits for that interrupt when it has started, so that it cannot reach a later request. The examples and the README close the client instead of its Conn.
…thout a panic After Close, a request whose attempt failed for another reason, such as a TCP reconnect's dial refused or timed out after the Close, ended with that error instead of net.ErrClosed. Once Close closed the client, an attempt that ends without a reply now ends the request with the error wrapping net.ErrClosed, whatever else ended it, for any net.Conn; a reply read before the Close still answers. The SetDeadline classification this rule supersedes goes. The TCP reconnect compared the client's Conn with the attempt's using ==, under the client's lock: a caller's Conn of a type == cannot compare (a struct with a func field, used by value) panicked there and left the lock held, so a later Close blocked forever. The comparison now never panics: values of different types differ, and two of a type == cannot compare count as the same. The commit releases the lock with defer. The Handler's Close doc no longer refers to a difference from GoSNMP.Close, and the Handler, like GoSNMP, is safe for concurrent Close. Tests: the Close-during-request test joins its closers before Connect (a late closer closed the new connection); new race-detector tests for Close during Connect, Close from another goroutine during a reconnect's dial and Close during a walk; a Close during a refused reconnect; an uncomparable caller Conn and an uncomparable hook Conn; a Context canceled while the attempt sets its deadline; a reconnect whose hook changed Timeout; a hook that removes Conn; a Close after the reply was read. The request scenarios let their own goroutines finish, so a regression shows as a transcript difference instead of a synctest deadlock.
…actly The reconnect counted a client's connection whose value == cannot compare as unchanged even when the attempt's connection could be compared, so a wrapper around another connection passed for the old one. Two connections now count as the same without == only when neither can be compared; == cannot panic when either can. TestSameConn pins the rule. The docs state the closed rule as the engine applies it: once Close has run, a request whose attempt ends without a reply fails with net.ErrClosed, whatever else ended the attempt. The reconnect's doc points at the replacement it cannot see. The real-socket Close and cancel tests use a shorter timeout and no retries, so a regression fails their own assertion.
ilyam8
marked this pull request as ready for review
October 14, 2026 09:41
Close closed only the connection Conn held, so after a hook replaced or removed Conn the attempt kept reading the connection it took until its timeout. Each attempt now registers its connection with the client after setting its deadline; Close and the request's Context interrupt the registered attempt through one rule: a deadline in the past, set outside the client's lock, keyed by the attempt's number so that a late interrupt cannot reach a later attempt, and waited for when the attempt ends. Close still closes only the connection Conn holds: a connection a hook replaced is the caller's again. A cancelable Context costs two allocations less per attempt.
A Conn the caller set got a record with the request's settings but without the agent's address an unconnected socket writes to, so a wrapper of the client's own unconnected socket, or a socket the caller opened, failed with "destination address required". The record of a Conn the caller set is now complete: with UseUnconnectedUDPSocket it carries the agent's address resolved from the request's Target and Port, as the socket Connect opens carries the one Connect resolved.
The TCP reconnect ran only when an attempt's read met io.EOF, and a refused reconnect kept the dead connection without recording it, so the client never recovered: the next write failed with a broken pipe on every later request. A connection the agent reset without closing it (a rebooted agent host) was never reopened at all. A TCP connection whose write or read fails otherwise than by the attempt's deadline or a close (net.ErrClosed) is now lost while Conn holds it, and the attempt that takes it opens it again first, the one place a TCP connection is reopened; a refused reopen leaves it lost for the next attempt, of the same request or a later one. A connection a hook replaced is not reopened, and a reply that fills the receive buffer, a framing failure rather than a lost connection, keeps its outcome. OnRetry now runs before the reopen's dial, and a request whose last attempt meets the agent's close leaves the reopen to the next request.
…esolved A Conn the caller set in unconnected UDP mode looked up the request's Target in every attempt. The lookup ran outside everything that bounds a request (its Context, Close, the attempt's timeout), and over the request's Transport, so a wrapper of the socket ConnectIPv6 opened wrote to an IPv4 address. Names are now resolved only by Connect and by a TCP reopen's dial: Connect records the agent's address it resolved for an unconnected socket (cleared by a Connect in connected mode, kept by Close), and a Conn the caller sets in unconnected mode writes to it, as before the connection record. A caller's socket without such a Connect has no address, as before.
…tion's opening Opening a connection resolved its local address, and an unconnected socket's agent, with net.ResolveUDPAddr and net.ResolveTCPAddr, which take no Context; only the dial had the Context and Timeout. A slow name service held Connect, and every TCP reopen after the agent closed or reset the connection, past the request's bounds, and a cancellation came back as a lookup error. Opening now takes one deadline from the Context and Timeout before its first step, and every lookup runs within it, through the resolver's Context-aware lookups and the standard library's rules for choosing among the addresses. A canceled or timed-out opening fails with an error that matches context.Canceled or context.DeadlineExceeded; a Close during a reopen's lookup ends the request once the Timeout passes. The docs state which Connect resolves the address an unconnected socket writes to.
…nection The Context-aware lookups that opening a connection uses since the previous commit took the addresses from LookupIP, which drops zones, so a name a hosts file maps to a scoped IPv6 address (fe80::1%lo0) lost its zone: a named local bind failed and an unconnected socket wrote to no route, where net.ResolveUDPAddr and net.ResolveTCPAddr keep the zone. On a network without a family the lookup is now LookupIPAddr, the standard library's query with the zones; on one with a family it stays LookupIP, the family's own query and errors, and a scoped IPv6 address among the answers takes its zone from LookupIPAddr. A zone a hosts file gives an address that is not scoped is kept on a network without a family, and dropped on one with a family, where keeping it would take a second query of every lookup.
stelfrag
approved these changes
Oct 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fourth step of the bug-fix phase: the client owns one connection, and Close can end a running request.
The client used to replace its connection in place and close nothing it replaced:
*net.UDPConninConn, which is not nil.Connnil when its dial failed, and dialed withthe client's Target, Port, LocalAddr and Control at that moment, so a hook redirected the running request.
Close was not safe while a request ran:
Connwithout the lock Close takes: a Close from another goroutine was a data race, and a requestafter it dereferenced a nil
Conn(the recover turned the panic into an error with stacks).Connitself: it panicked without a connection and closed it again on a second call.Netdata's SNMP topology calls it from a goroutine to cancel walks, and worked only because of that.
A cancelled Context was noticed only when the attempt's deadline passed.
Now:
net.Conn, the settings that open it again, and the agent'saddress of an unconnected socket.
one. A failed Connect or reopen leaves the client as it was.
net.ErrClosed), islost: the agent closed it (EOF) or reset it. The next attempt, of the same request or a later one, opens it again
before writing, with the settings it was opened with (a
Connthe caller set: with the request's configuration),within the request's timeout and Context; a refused reopen leaves it lost for the attempt after, so the client
recovers once the agent accepts again. This is the only place a TCP connection is reopened. A connection a hook
replaced is not reopened.
the lookups of the local address and of an unconnected socket's agent run within it, as the dial does, with the
standard library's queries and rules for choosing among the addresses, zones of scoped IPv6 addresses included. One
intended difference, documented in
lookupIPAddrs: on a network with "4" or "6", a zone a hosts file gives anaddress that is not scoped (IPv4, global IPv6) is dropped; keeping it would take a second lookup of every name. A canceled or timed-out opening fails with an error that
matches
context.Canceledorcontext.DeadlineExceeded.address it resolved for an unconnected UDP socket (a Connect in connected mode clears it; Close keeps it); a
Connthe caller sets with
UseUnconnectedUDPSocket, such as a wrapper of the client's socket, writes to it. A caller'ssocket without such a Connect has no address, as before.
cancelled interrupt the registered attempt through one rule: a deadline in the past on the attempt's connection,
set outside the client's lock, keyed by the attempt's number so a late interrupt cannot reach a later attempt, and
waited for when the attempt ends. A Context that cannot be cancelled costs nothing.
hook may have replaced
Conn), and closes the connectionConnholds; a connection a hook replaced is thecaller's again. Once Close has run, an attempt that ends without a reply ends the request with an error wrapping
net.ErrClosed, whatever else ended it (a refused reopen, a timeout, a cancelled Context), for anynet.Conn;later requests fail the same way until Connect opens a connection or
Connis set.Connis nil after it, andcalls after the first return nil. Close does not cut the dial of a TCP reopen short; its timeout and the Context
bound it.
Connof a type==cannot compare counts asunchanged.
Behavior changes:
net.ErrClosedConnduring the attemptnet.ErrClosednet.ErrClosedConnnil, old connection leakedOnRetry; old connection closed; dials the connection's settingsConnnil: every later request failsLocalAddr, unconnected agent)Tests
from another goroutine wakes a waiting read; after Close, I/O fails with
net.ErrClosed), with the existingtranscripts unchanged, and pins of the old behavior: Close before, during and between attempts, the Handler's
Close, a second Connect, the TCP reconnect's old connection, a refused reconnect and a hook that redirected it.
net.ErrClosed; cancellations end at the cancellation.the Handler, two goroutines at once, as Netdata does); Close during Connect; a Context cancelled on a real socket and
while the attempt sets its deadline; the interrupt held until after the reply arrived; a Context cancelled after its
request; Close during the TCP reconnect's dial (refused and opened, on the request's goroutine and on another); a
Conna hook set during the attempt; a caller's or a hook'sConnof a type==cannot compare; the reconnect'ssettings, Context and timeout for a
Connthe caller set; a Close after the reply was read.Conn(with and without a Context); a Close while theContext's interrupt is still setting its deadline; a Close after a request whose hook replaced
Conn; each attemptunregisters its Context interrupt; recovery after the agent closes the TCP connection and two reopens are refused,
after a reset, and none after a timeout or a close; a reopen's dial bounded by the request's Context; where an
unconnected UDP client writes for its own socket, a wrapper of it (also after ConnectIPv6) and a socket the caller
opened, after Close and after a Connect in connected mode; no name lookup during a request; a Context interrupt that
runs late leaves the next attempt alone; Connect's and a TCP reopen's lookups within the Context and Timeout (both
connection origins; cancel, Timeout, Close), with the address choice checked against
net.ResolveUDPAddrandnet.ResolveTCPAddron 102 inputs over a stub DNS; zones of names a hosts file maps to scoped IPv6 addresses.Validation
netsnmpplayback pass.GOARCH=386 and the end-to-end suite run in CI.
costs about 5 allocations and 180 ns per attempt.
allocation-only change). Removing any of the six lock sites fails under
-race. The goroutine tests pass 50 runseach with one and two CPUs under
-race.reconnect ending without
net.ErrClosed, a callerConnof an uncomparable type panicking under the lock (whichthen stayed held), a flaky test on few CPUs, a stale Handler doc, and test gaps (two lock sites, the order of the
deadline and the interrupt, the reconnect's timeout). All fixed with regression tests in the third commit; the
follow-up review's precision and wording notes are in the fourth.
not reach the connection an attempt used after a hook replaced
Conn(fifth commit: one interrupt rule for Closeand the Context); a caller's unconnected UDP socket lacked the agent's address (sixth); a refused TCP reconnect never
recovered, because only an EOF read triggered it and later writes failed with a broken pipe (seventh: the lost
rule, which also recovers a connection the agent reset); the eighth and ninth fix comments. A re-review of those
found the sixth commit's per-attempt name lookup ran outside the request's Context, Close and timeout and lost
ConnectIPv6's address family: the eleventh commit replaces it (names are resolved only by Connect); the tenth pins
the late-interrupt guard the re-review showed testable. A full review then found Connect and a TCP reopen resolving
a named local address outside the Context and Timeout: the twelfth commit gives the whole opening one deadline; the
next review found its lookups dropped the zone of a name's scoped IPv6 address, which the thirteenth keeps. All 59
mutants of these fixes are killed. A cancellable Context costs two allocations less per attempt than before
these fixes; other benchmarks are unchanged.