fix(registry/client): idempotent Close; pool reconnect keeps its cause, logs at Debug, backs off, retries on a healthy conn - #52
Merged
Conversation
…e, backs off, retries on a healthy conn
Close (sweep item 8)
- Client.Close was not idempotent: it always ran close(c.pool.done), so a
second Close panicked with "close of closed channel". Every nightly since
07-26 hits this when the daemon's forceReconnectRegistry `go old.Close()`
races Stop's Close.
- Close now runs its teardown once (sync.Once). Later and concurrent calls
return nil. The teardown closes every pool connection exactly once and
interrupts in-flight requests instead of waiting on them.
- A redial that finishes after Close closes its own connection, so it does
not leak into the closed client.
- Close wakes requests parked on the free list or in a reconnect backoff.
The single-conn path runs its backoff with c.mu held, so without this
wake-up Close would wait out the backoff.
- Every request after Close, and every request that Close interrupted,
fails with the new ErrClosed. errors.Is(ErrClosed, ErrNoRegistry) is true,
so callers that already treat a missing registry as recoverable need no
change.
- BinaryClient.Close gets the same treatment.
Pool reconnect (client side of sweep item 5)
- The error that triggered a reconnect is no longer thrown away. It is logged
with the drop and the redial (cause=...). When the redial fails, the
returned error wraps both errors:
"send failed and reconnect failed: <cause> (reconnect: <err>)".
When the retry fails too, its error names the first failure.
- Per-reconnect success lines ("registry pool conn reconnected",
"registry reconnected") move to Debug. They were 113,804 lines, 57% of
daemon.log, in 5.5 days. In their place is one INFO line, "registry
reconnect summary", at most once a minute and flushed on Close. It carries
the counts, rate_limit_suspected and last_cause.
- Jittered exponential backoff:
- A client-wide cooldown before a redial grows with consecutive rapid
closes and with reconnects that fail every attempt. A rapid close is the
peer closing or resetting a connection younger than 30s with no error
frame, which is the registry rate limiter's signature.
- The cooldown starts at 250ms and is capped at 2s, well under the
daemon's 8s registry deadline.
- It resets after an answer on a connection at least 30s old, after a
successful redial (failure streak only), or after a quiet minute.
- The attempts inside one reconnect are now jittered and no longer sleep
after the final attempt.
- After a closed-connection error on one pool entry, the request is retried
once on another idle healthy entry before any redial. The dropped entry is
marked broken and its fd closed at once. Healthy entries are preferred,
and a broken entry is redialed lazily when it is next needed. Timeouts
still go straight to the redial.
- SendContext now also honours ctx while it waits for a free pool entry.
Public API: additive only (ErrClosed). Error texts keep their old
substrings ("client closed", "send failed and reconnect failed",
"reconnect failed after 5 attempts").
Tests (zz_client_close_reconnect_test.go) use a scripted fake registry that
closes connections after N requests or after a grace period.
- Double and concurrent Close under -race, with concurrent Sends.
- Each pool connection is closed exactly once.
- Close during a redial, and Close waking a reconnect backoff.
- Debug logging with the cause, plus the periodic INFO summary.
- Retry on another entry, and the fallback to a redial.
- Backoff growth, jitter bounds, cap and reset, with timing checked against
lower bounds only.
- Both errors kept on failure, and error classification.
Two proxy tests now hold the other pool entry so their dropped request
still has to take the redial path they check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nistic TestDialPoolPartialSecondaryFailureClosesPrimary closed the listener after the first accept to make a secondary dial fail. A secondary dial can land in the listen backlog before that close, and then DialPool succeeds. On origin/main this failed 13 times in 600 runs (-race -cpu 1,4). The test now uses WithDialer to fail the third dial. It also checks that the primary and the secondary that did open are each closed exactly once. 0 failures in 600 runs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…r request Review fixes for the pool retry on another connection: - F1: the retry on another idle pooled conn after a drop ran with the 30s read deadline and ignored ctx, so a silently hung other conn stalled a request that had failed fast (30.2s vs 1ms on main, past the daemon's 8s registry deadline). The retry's whole exchange is now bounded by otherConnTimeout (2s) and by ctx, both its deadline and its cancellation. A conn that times out or is abandoned is retired, since a late reply could still arrive on it, and the request falls back to the redial. dialWithBackoff returns at once when ctx is already done, and a dial abandoned for ctx is neither logged as a failed dial nor counted toward the failure streak. The reviewer's repro now returns in 2.2s. - F2: a pooled request can reach the registry three times (own conn, other conn, redialed conn), but SendContext said "retried once". The doc now states the limits (twice single-conn, three times pooled) and that an operation not safe to repeat may have been applied. Each failed transmission also extended the rapid-close streak, so one shed request doubled the next cooldown. A logical request now extends the streak at most once; every close still shows in the summary. Tests: bounded retry on a hung other conn, ctx deadline and cancel ending it with no redial, at most three sends and one streak step per pooled request, at most two sends single-conn, a cancellation racing a bounded exchange never leaves a deadline behind, and the default timeout budget. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
Client-side fixes for two verified findings from the 2026-09-24 reliability sweep: item 8 (a
Closepanic in every nightly) and item 5 (pool reconnect churn, 57% ofdaemon.log). The server-side part of item 5 (the rendezvous limiter closing connections with no error frame) is out of scope here.Item 8:
Client.Closeis idempotent and race-freeBug.
Closealways ranclose(c.pool.done), so a secondClosepanicked withclose of closed channel. This happens in every nightly since 07-26 (common v0.5.10 to v0.5.13), for example run 35825441421. The daemon'sforceReconnectRegistryrunsgo old.Close(), which races theCloseinStop(). The panic also aborts gotestsum's rerun pass, so every other nightly failure shows as(unknown). I reproduced it on origin/main with two concurrentClosecalls on aDialPool(…, 4)client.Fix.
sync.Once). Later and concurrentClosecalls returnnil.c.mu, so nothing closes a connection twice.Closeno longer waits for in-flight pooled requests. It closes their connections, and those requests returnErrClosed.Closecloses its own connection, so it is not leaked into a closed client.Closefirst closes adonechannel. That wakes requests waiting for a free pool entry or sleeping in reconnect backoff. The single-conn path sleeps withc.muheld, so without thisClosewould wait out the backoff.ErrClosed, witherrors.Is(ErrClosed, ErrNoRegistry) == true. EverySendafterClose, and every requestCloseinterrupted, returns it, never a panic. The interrupted ones wrap the I/O error. Callers that already treat "no registry" as recoverable need no change. The old "client closed" substring is kept.BinaryClient.Closegets the same treatment: idempotent,ErrClosedafterwards, and itstime.Sleepbackoff (which also runs underc.mu) now wakes onClose.Still needed in web4 (not in this PR): have
forceReconnectRegistryreturn early oncestopChis closed, and closenewConnifStopwon the race. With this PR the race can no longer panic, but a reconnect that runs afterStopstill dials a pool nobody uses.Item 5 (client side): reconnects keep their cause, log quietly, back off, and try another conn first
Problem.
sendPoolreconnected and retried, but threw away the error that caused it. It logged every successful redial at INFO: 113,804registry pool conn reconnectedlines in 5.5 days, 57% of the log. It also redialed immediately, even when the registry's limiter was shedding the connection.Fix.
registry conn dropped, witherr,conn_ageandrapid_close.cause=<triggering error>.send failed and reconnect failed: recv: EOF (reconnect: reconnect failed after 5 attempts: dial …: connection refused).errors.Isworks on both.(retried after reconnect; first failure: …).registry pool conn reconnectedandregistry reconnectedmove to Debug. The first event in a window arms a timer. One minute later it logs one INFO lineregistry reconnect summarywithwindow,reconnects,failed_dials,rapid_closes,served_by_other_conn,rate_limit_suspected,backoff_streakandlast_cause.Closeflushes the last window. So a burst is still reported even if nothing follows it.withRegistryDeadline, so backoff alone does not trigger the half-open forced reconnect.otherConnTimeout(2s) instead of the 30s read deadline, and ends early whenctxis done (deadline or cancellation). An entry that times out or is abandoned is retired, because a late reply could still arrive on it.SendContextdoc says so, and warns that an operation that is not safe to repeat may have been applied even when an error comes back. However many attempts fail, one request extends the rapid-close backoff streak once.SendContextnow honoursctxwhile waiting for a free pool entry and during the other-entry retry. A reconnect whosectxis already done returns at once, and a dial abandoned forctxis not logged or counted as a failed dial.Trade-offs to be aware of. If the other idle connection is silently hung, the request now pays up to 2s before the redial (main redials at once). The 2s cap plus the 2s cooldown cap leaves half of the daemon's 8s deadline for the redial. The hung connection is retired in the process; on main the next request sent on it would stall for the full 30s.
While the registry keeps shedding connections past the grace period, the backoff adds 125ms to 2s before a redial that would otherwise have succeeded at once (inside the new connection's grace). That is the point: stop redialing into the limiter. It costs latency until the rendezvous fix lands: an error frame with
retry_after_ms, and a resized or sharded global bucket.Compatibility
ErrClosed). No exported signatures change.go build ./...andgo test -race ./pkg/daemon/, pointing web4 at this branch through a scratch-modfilereplace. The web4 checkout was not modified.Tests
New file
registry/client/zz_client_close_reconnect_test.go. Its fake registry closes connections without an error frame, either after N requests or after a grace period.Close, and three concurrentClosecalls racing 8 senders onDialandDialPool, 20 iterations under-race. Every failure matchesErrClosedandErrNoRegistry.Closeduring a redial.Closewaking a reconnect backoff, on both the pooled and the single-conn path.BinaryClient: idempotentClose, andClosewaking its backoff.Close.otherConnTimeout, retires that conn and is served on the redial. Actxdeadline or cancellation ends it, with no redial and no failed-dial WARN.TestDialPoolPartialSecondaryFailureClosesPrimarywas flaky on origin/main (13 failures in 600 runs). It now fails the dial deterministically throughWithDialer: 0 in 600.Test runs.
GOWORK=off go test -race ./... -count=1,go vet ./...(also with GOOS=windows and GOOS=linux) and staticcheck on the package are all clean. gofmt is clean on changed files; four untouched files were already unformatted on main. The package passed 10 runs at each of-cpu 1,4under-race(again after the review fixes). Package coverage is 94.0%. The package test time dropped from ~31s to ~8s because the final-attempt sleep is gone.Review fixes (3eb5b6b)
F1 (medium): the other-entry retry could stall a request for 30s. It ran with the fixed 30s read deadline and ignored
ctx. Reviewer repro (conn 1 closes on its first request, conn 2 reads but never replies): 30.2s with 3 requests at df4fc71; 2.2s now with the default policy (main: 1ms). The fix is the bound described in item 4.F2 (low): up to 3 transmissions, while the doc said "retried once". The doc now gives the real limits. Each failed transmission also extended the rapid-close streak, so one denial set the cooldown to jitter(500ms) instead of jitter(250ms). The streak now grows once per logical request: the reviewer's 3-transmission case takes 0.21s instead of 0.46s. I kept the third attempt and did not add a list of non-idempotent message types, for three reasons:
Registerwithout a key, is sent only by pilotctl, on a single-conn client (at most 2 sends, as on main). The daemon usesRegisterWithKeyOpts, which returns the same node_id.A per-type list over ~60 message types in a generic
Sendwould also go stale.Re-checked after the fix:
-race): every conn is closed exactly once, no send hangs or succeeds after Close, and goroutines return to baseline.go test -race ./pkg/daemon/passes against this branch (through a scratch-modfilereplace).Follow-ups (not in this PR)
{type:error, error:"rate limited", retry_after_ms}before closing, charge the per-IP bucket from the first request, and resize the global bucket. Server half of item 5.forceReconnectRegistrystop check (item 8), then bump common.🤖 Generated with Claude Code