Repository navigation
feat: bind group sockets without waiting for the upstream agent - #57
Conversation
…erving An upstream agent that accepts a connection but never answers the initial identity listing kept the proxy from binding any group socket until an operator intervened. Bound that one request to 10 seconds and treat a timeout like any other failed startup listing: serve, and resolve group keys on the first client request. The deadline covers the whole connection, so the listing makes a single attempt and replaces the connection once the deadline fires. A late reply can never be matched to a later request, and the first signing request does not fail on the expired connection. Requests after startup, including interactive signing, stay unbounded. Closes #56. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
krisiasty
left a comment
There was a problem hiding this comment.
Code review (high): 9 findings. Five affect correctness or behaviour and four are cleanup. They have not been verified separately.
The design comment on server.go:200 may make several of the others unnecessary: if socket binding no longer waits on the startup listing, the timeout isn't needed.
🤖 Generated with Claude Code
Replace the bounded startup listing with the fix the review asked for: socket binding no longer waits on an upstream round trip it does not need. Run claims each group's startup refresh, starts one shared upstream List in the background, then binds the sockets and reports readiness. Clients that arrive first wait for that listing instead of sending their own, and teardown waits for it after interrupting upstream I/O so no client is left waiting. No upstream request has a time limit any more, so an agent that holds a listing while it shows an unlock prompt is never cut off, nothing caches a synthetic timeout, the startup listing keeps the normal reconnect and retry, and upstream.go and cache.go are unchanged from main. The app tests that held a runtime in startup through a stalled listing now use a newRuntimeServer test seam, since a hung upstream no longer delays readiness. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
krisiasty
left a comment
There was a problem hiding this comment.
Code review (high), re-review of d16e42f. All 9 findings from the previous review are resolved: listWithin, listUsing, startupTimeout and the logCtx parameter are gone, and upstream.go and cache.go match main.
This review has 9 new findings: three correctness or behaviour issues introduced by the new design, three test-coverage problems, and three cleanups. They have not been verified separately.
🤖 Generated with Claude Code
- Start the shared startup listing once every socket is bound (or Run is canceled), so key counts are always logged after "serving group". - Keep each group's startup refresh in enrichedGroup and claim it with joinRefresh directly, removing claimRefresh and its unreachable nil paths. - Pass the runtime server factory through runWithReload and launchRuntime instead of overriding package state, and let the test gate hold the group socket locks while a runtime is held in startup. - Add a per-server hook for clients joining a refresh in flight, so the startup tests wait for a real join instead of polling a counter. - README: binding still needs the upstream connection, only not an answer from the agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Binding the group sockets no longer depends on the upstream at all. reconnectClient dials its first connection on first use instead of at construction, so an agent that is not running yet, for example a password manager that starts after the proxy at login, no longer makes the runtime fail and idle. Requests fail while the agent is unavailable, and the first request after it starts connects to it. Startup failures now come only from configuration and group sockets, so the idle message no longer points at the upstream agent. Tests that used a missing agent to make startup fail now occupy the group socket instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
All clients share one upstream connection and the agent answers its requests in order, so a request the agent never answers left every later request waiting forever, even after the agent recovered. Clients connecting to the agent directly would recover because each opens its own connection. A per-request watchdog now drops the connection when a request has gone unanswered for 60 seconds. The requests waiting on it fail, as they would against a hung agent anyway, and the next request dials a fresh connection, so the proxy recovers on its own once the agent answers again. A dropped request is not retried, and its error says the agent left a request unanswered. The startup listing is an ordinary request, so it is covered by the same mechanism. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The shutdown test's list scenarios stall the upstream from its second List, assuming the startup listing is the first. The startup listing now runs in the background after readiness, so a fast client could join the startup refresh instead of sending its own List, and the expected stall never came (30 of 90 runs with -cpu 1,2,4). Wait for the startup resolution first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A connection dialed on demand after an earlier one, such as after the request watchdog dropped a stuck connection, was logged as "upstream connected" and not counted in the reconnects telemetry. Only the first connection is now reported as connected; later ones count as reconnects and log "upstream reconnected", like a replacement dialed after a transport failure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pull request
Summary
An upstream agent that accepts a connection but never answers the initial identity listing kept the proxy from binding any group socket, so clients saw a missing socket until an operator stopped or reloaded the service. Socket binding never needed the upstream: the startup listing only pre-loads authorization and logs key counts, and the connection is only needed once a client asks for something. This change removes both dependencies, and makes the proxy recover on its own once the agent answers again.
reconnectClientopens its first connection on first use instead of at construction. An agent that is not running yet, for example a password manager that starts after the proxy at login, no longer makes the runtime fail and idle: requests fail while it is missing, and the first request after it starts connects. A first dial that completes during shutdown is discarded.Runclaims each group's startup refresh, binds the sockets, reports readiness, and then runs one shared upstream List in the background (or as soon asRunis canceled). Binding and readiness never wait for the upstream, and key counts are always logged afterserving group.List, with its reconnect and single retry after a transport failure.reconnectstelemetry and logsupstream reconnected; only the very first connection logsupstream connected.serving groupto a newgroup keys resolvedline, logged when the background listing completes.runWithReloaddelegates torunWithServers, which takes the factory that builds each runtime's proxy server. Six reload and rollback tests from fix: unblock shutdown and reload when the upstream agent stalls #55 held a runtime in startup through a stalled listing; a hung upstream no longer delays readiness, so they now pass a test gate that holds the runtime in startup while it owns its group socket locks. Tests that used a missing agent to make startup fail now occupy the group socket instead.This differs from the approach proposed in #56. That issue kept "list, then bind" and a startup-only deadline, because a connection-wide deadline would interrupt other requests sharing the connection. Binding no longer waits for the listing, and since the agent answers in order, requests queued behind a stuck one cannot complete anyway, so one uniform watchdog replaces the startup deadline. A first version of this PR bounded the startup listing to 10 seconds with a synchronous replacement dial; review showed it cut off slow unlocks, cached a synthetic timeout, lost the startup retry, and could take 20 seconds. That code is gone:
cache.gois unchanged frommain, and the watchdog reuses the lazy dial instead of dialing a replacement synchronously.Closes #56.
User impact
Group sockets are available immediately after startup or reload, whatever state the upstream agent is in, including when it is not running yet. Client requests fail while the agent is missing and wait for a slow, locked, or hung agent, as they would at any other time; they are interrupted when the service stops or reloads.
If the agent leaves a request unanswered for 60 seconds, that request and any queued behind it fail and the proxy reconnects on the next request. An unlock or approval that takes longer than that fails only the waiting request; once the agent is unlocked, the next request succeeds.
A missing upstream agent no longer makes a startup or reload fail, so it no longer causes the service to idle or a reload to roll back. Startup failures now come only from configuration and group sockets, and the idle message says "fix the problem" instead of "fix the upstream agent".
A runtime now becomes ready, and its configuration becomes the one restored after a failed reload, as soon as its sockets are bound, even while its upstream is still unanswered.
Log consumers that read the key count from
serving groupshould use the newgroup keys resolvedentry.Validation
SSH_AGENT_PROXY_REQUIRE_OPENSSH=1 go test -race ./... -count=1.proxyandapppackages pass thirty race-enabled runs each across-cpu 1,2,4; twenty race-enabled repetitions of the startup, resolution, reload, rollback, shutdown, reconnect, cache, telemetry, and signing tests.configuration reloadedand serves immediately; the proxy serves before the upstream agent starts and connects on the first request after it does; a first dial completing during shutdown is discarded; key counts are logged afterserving group.reconnectstelemetry); requests queued behind an unanswered one fail with it; an answered connection is left alone; and the server recovers when the agent never answers its first connection but answers new ones.ssh-add -lclient waits on the pending listing, a reload serves again immediately, and the agent received exactly one List per runtime; with no agent running, the socket is bound immediately,ssh-add -lfails in 0.02 seconds instead of hanging, and after the agent starts the next request connects and is served. with an agent that never answers its first connection but answers new ones, a client waiting on the stuck startup listing fails after 60 seconds and the nextssh-add -lis served over a new connection. SIGTERM exits with code 0 in every case.go vet ./...(also withGOOS=windowsfor the changed packages), formatting, andgit diff --check.No dependencies changed. The vulnerability baseline reports the known advisory for unused
x/crypto/openpgp.Checklist
go test -race ./...passes locally, or the reason it was not run is documented.🤖 Generated with Claude Code