Skip to content

h1client: a dial in progress cannot be cancelled, so a SYN retransmission at the end of a run stretches Run's elapsed time and deflates requests_per_sec #95

Open

Description

@FumingPower3925

Found in: review round 4 of #90 (protocol lens). This predates #90: main's reconnect dialed inline too, and also held hc.mu across the dial, which #90 removes. #90 makes it matter more, because in -close mode every request now dials.

What happens

The H1 dial takes no context, so nothing can interrupt it once it has started:

At the end of the measured window, Run cancels runCtx (bench.go:767), closes the client (:776), waits for the workers (:778), and only then takes elapsed := time.Since(start) (:781). RequestsPerSec is reqs / elapsed (:1092).

So a worker that started a dial just before the end holds b.wg.Wait() until the dial returns, for up to DialTimeout, which defaults to 10 s (bench.go:449). (A timeout is not retried; only a transient RST is, with short sleeps.)

When it bites

A dial blocks for seconds when the SUT's accept queue is full: Linux drops the SYN (tcp_conn_request, sk_acceptq_is_full), and the client resends it 1 s, 3 s and 7 s after the first SYN. That is exactly the state churn-close (probatorium, -close, PoolSize=1) drives a server into.

  • A 30 s window followed by a 3 s stall reports saturation RPS about 10% low (reqs / 33 s instead of reqs / 30 s).
  • In rated mode, the step's duration stretches the same way.
  • The stall is not a request: the dial's request is dropped (ctx.Err()) and is neither a success nor an error, so nothing in the Result shows it.

Keep-alive runs dial only after a failure, so they are exposed much less.

The H2 paths share dial.go (h2client.go:476, :487; h2c_upgrade.go:146).

Possible fixes

Either or both:

  1. Make the dial cancellable: dialTCPRetry and dialTLSRetry take a context and use (&net.Dialer{Timeout: timeout}).DialContext / tls.Dialer.DialContext, with a context-aware sleep between retries. redial passes the request's context, which is runCtx, cancelled at bench.go:767. newH1Client's up-front dials can keep context.Background().
  2. Take elapsed when the window ends (when running flips false), not after wg.Wait(), so a straggler cannot stretch the denominator.

A test: a listener with a full backlog (or a dialer hook that blocks until its context is done), a run of 300 ms, and an assertion that Run returns within a small bound and that Duration is close to the configured window.

Activity

  1. FumingPower3925 commented on Sep 27, 2026

    @FumingPower3925
    ContributorAuthor

    The HTTP/2 client has the same exposure, plus a lock that spreads it. This came from the round-3 review of #91 (protocol lens), and it predates #91.

    The same fix covers it: a context-aware dial, as proposed above. The handshake read must also end when the context does, for example with context.AfterFunc(ctx, func() { _ = conn.SetDeadline(time.Now()) }) around completeH2Handshake and the upgrade's read. With that in place the lock is released as soon as the run's context ends, and Close, which runs after the cancel, no longer waits behind a dial.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions