Skip to content

fix: read a request's configuration once and never write it - #62

Merged
ilyam8 merged 3 commits into
attempt-outcomesfrom
request-config
Oct 14, 2026
Merged

ilyam8 merged 3 commits into
attempt-outcomesfrom
request-config

Conversation

@ilyam8

@ilyam8 ilyam8 commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Third step of the bug-fix phase: a request reads its configuration once, at its start, and never writes it.

The engine used to read the client's configuration live during a request:

  • Retries and ExponentialTimeout at each retry decision, the Context at each attempt, and Transport for the TCP
    reconnect;
  • the hooks at each call;
  • the client's version and security model, in three guards that compared the client with the packet.

So a hook or the Logger that changed the client changed the running request. With the version changed after the
negotiation, the request went out with msgID 0.

The library also wrote configuration as working state:

  • A request clamped a negative Retries to 0.
  • Connect appended the IPv4/IPv6 suffix to Transport on every call, so a second ConnectIPv4 dialed "udp44", and
    narrowed it to IPv4 for an IPv4 local address.
  • Connect reseeded the ID counters. This discarded SetRequestID/SetMsgID and made a second Connect replay the
    same IDs.

A client given a Conn without Connect panicked, for want of a Context and a receive buffer.

Now:

  • send builds one request configuration: the retries (a negative value counts as 0), the timeout, the exponential
    timeout, the Context (a background one when the client has none), the network and the hooks. It allocates the
    receive buffer the first time. The engine reads only this configuration and the packet.
  • The packet answers the version, security level, model and context:
    • the guards are gone;
    • the msgID follows the packet's version;
    • discovery takes the packet's context name;
    • the reply-model check compares the reply with the request.
  • The ID counters start at a random value on first use, unless SetRequestID or SetMsgID set them. Connect leaves
    them alone.
  • Connect derives the network (suffix, IPv4 narrowing) and records it with the connection it opened, which a request
    uses only while Conn is that connection. Transport is left as set. Connect still fills in its documented
    defaults: "udp", MaxOids, a background Context and Reportable.
  • Three things are not read once, and the docs name them:
    • the Logger;
    • the SNMPv3 engine state the client learns;
    • the TCP reconnect, which dials with the client's Target, Port, LocalAddr and Control at that moment. A later
      step rewrites it with the connection's ownership.
  • The random start of the ID counters is below 2147483647, so the first ID is never 0, which a Response may carry for
    any request.

Behavior changes:

  • A hook's or the Logger's change to the client applies from the next request.
  • Retries and Transport keep the caller's values.
  • IDs survive Connect, and so do SetRequestID/SetMsgID. A client that never connected draws random IDs.
  • A Conn given without Connect works, provided the caller sets the fields Connect would have filled in
    (MaxOids, for one).

Tests

  • First commit: four scenarios where OnRetry changes Retries, ExponentialTimeout, the Context or a hook,
    pinning the old behavior.
  • Golden flips:
    • the requests over a Conn without Connect succeed;
    • the hook scenarios no longer affect the running request (now including replacing every hook, and a Logger that
      changes the context name);
    • the SNMPv3 hook cases complete;
    • a negative Retries keeps its value.
  • TestConnect records the derived network next to Transport. TestRequestAndMsgIDs pins the new counter rules,
    each counter's random start on its own.
  • A real-socket test sends a request over a TCP Conn the caller set, without a Context, with a hook that changes
    Transport. It covers a client that never connected, one that connected over UDP and closed, and one whose
    connection was replaced. The request still reconnects over TCP each time.
  • From the reviews:
    • every request scenario records the configuration fields its run changed, so a write by the library would show next
      to the hooks' own;
    • SNMPv3 scenarios where the discovery and the resync exchanges' hooks change the client;
    • a hook that sets a Context with a deadline.

Validation

  • Local: golangci-lint, race tests, FIPS 140-only, six cross-builds, govulncheck and netsnmp playback pass.
    GOARCH=386 and the end-to-end suite run in CI.
  • Benchmarks:
    • allocations and bytes are unchanged;
    • Get costs about 21 ns more per request (+2.7% on the in-memory benchmark), for building and copying the
      configuration;
    • that is a fixed, allocation-free cost against a network round trip.
  • 33 mutants of the changed code: all killed but one with a 2^-31 effect.
  • Two independent reviews (correctness of the invariant; test integrity and docs). They found a stale network after
    Close (fixed), an unpinned per-counter random start, gaps in the coverage of the "read once, never write" rule
    (closed), and docs that needed to name their exceptions.

Four scenarios pin what a request does when OnRetry changes the client:
Retries set to 0 ends the request one attempt early, ExponentialTimeout
doubles the next attempts' timeouts, a canceled Context ends the request,
and a replaced OnSent runs in the next attempt. The running request reads
the client's configuration at each step.
The engine read the client's configuration live during a request: Retries
and ExponentialTimeout at each retry decision, the Context at each attempt,
Transport for the TCP reconnect, the hooks at each call, and the client's
version and security model, the latter in three guards that compared the
client with the packet. A hook or the Logger that changed the client
changed the running request: with a version changed after the
negotiation, the request went out with msgID 0. The engine also wrote
configuration as working state: a request clamped a negative Retries to 0,
Connect appended the IPv4/IPv6 suffix to Transport on every call (a second
ConnectIPv4 dialed "udp44") and narrowed it to IPv4 for an IPv4 local
address, and Connect reseeded the ID counters, discarding SetRequestID and
SetMsgID and replaying the same IDs after a second Connect. A client given
a Conn without Connect panicked for want of a Context and a receive buffer.

A request now builds its configuration once, at its start: the retries
(a negative value counts as 0), the timeout and exponential timeout, the
Context (a background one when the client has none), the network and the
hooks; it allocates the receive buffer the first time. The packet answers
the version, security level and model, and the context: the guards are
gone, the msgID follows the packet's version, discovery takes the packet's
context name, and the reply-model check compares the reply with the
request. The ID counters start at a random value on first use unless
SetRequestID or SetMsgID set them, and Connect leaves them alone. Connect
derives the network into an unexported field and leaves Transport as set;
it still fills in its documented defaults.

A hook's or the Logger's change to the client now applies from the next
request; a Conn given without Connect works, given the fields Connect
would have filled in.
…guration

The derived network outlived the connection it described: after Close,
or with another Conn set in place of the one Connect opened, a request
took the old network instead of Transport, so a TCP Conn the caller set
no longer reconnected when the agent closed it; a failed dial also left
a network behind. The client now records the network of a connection
only when it opens one, together with that connection, and a request
uses it only while Conn is that connection.

A random start of 2147483647 made the first ID 0, which a Response may
carry for any request; the start is now below it.

Tests: every request scenario records the configuration fields the run
changed, so a library write shows next to the hooks' own; SNMPv3
scenarios where the discovery and the resync exchanges' hooks change the
client, and one where OnRetry sets a Context with a deadline; each ID
counter's random start on its own; the caller-set TCP Conn test after an
earlier Connect and Close, and with the Conn replaced.

Docs name what a request does not read once: the Logger, the SNMPv3
engine state the client learns, and the TCP reconnect's dial settings.
@ilyam8
ilyam8 added this pull request to stack #61 October 14, 2026 07:10
@ilyam8 ilyam8 changed the title request config fix: read a request's configuration once and never write it Oct 14, 2026
@ilyam8
ilyam8 merged commit 4b1f09e into master Oct 14, 2026
22 checks passed
@ilyam8
ilyam8 deleted the request-config branch October 14, 2026 08:05
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