Buffer remote command output in the watch daemon - #552
Conversation
|
The I think the simpler design is to resolve credentials in the This also eliminates the mutex-around-slow-keychain-I/O problem on the hook path. Tracked in FACT-460. |
3a8fb3f to
cd058ef
Compare
A client captured its token at construction and kept it for life, so a process that outlives a login never saw the new credential. The watch daemon is the case that motivated this: it builds one client at startup and holds it until it exits, so a token stored afterwards was invisible to it. On a 401, re-read the stored credential and retry once if it changed. The reload is opt-in via httpcl.Config.ReloadToken, threaded through circleci.Config, and wired in authprompt.ResolveCircleCIClient — the one place that already knows how to resolve a token. Deliberately a reload rather than a refresh: internal/oauth requests grant_type=authorization_code only, stores no refresh token, and drops expires_in, so there is no grant to call. The only way a token improves is if something else stored a new one. Retry is bounded to one attempt and gated on the token actually having changed, so a revoked token costs one extra read rather than one per request. Concurrent 401s collapse into a single reload: the token is guarded by a mutex and later arrivals see it has already moved. Request bodies are retained rather than streamed once, since the first attempt drains the reader and the retry needs its own copy. Follow-up to the credential work in #552, which removed the daemon's own lazy re-resolution and left this as the only place expiry can be handled.
The daemon owns a registered command's stream, so output outlives the process that submitted it - for a hook-driven run, that is immediately. Both validate and sidecar exec register through one helper.
A client captured its token at construction and kept it for life, so a process that outlives a login never saw the new credential. The watch daemon is the case that motivated this: it builds one client at startup and holds it until it exits, so a token stored afterwards was invisible to it. On a 401, re-read the stored credential and retry once if it changed. The reload is opt-in via httpcl.Config.ReloadToken, threaded through circleci.Config, and wired in authprompt.ResolveCircleCIClient — the one place that already knows how to resolve a token. Deliberately a reload rather than a refresh: internal/oauth requests grant_type=authorization_code only, stores no refresh token, and drops expires_in, so there is no grant to call. The only way a token improves is if something else stored a new one. Retry is bounded to one attempt and gated on the token actually having changed, so a revoked token costs one extra read rather than one per request. Concurrent 401s collapse into a single reload: the token is guarded by a mutex and later arrivals see it has already moved. Request bodies are retained rather than streamed once, since the first attempt drains the reader and the retry needs its own copy. Follow-up to the credential work in #552, which removed the daemon's own lazy re-resolution and left this as the only place expiry can be handled.
8119705 to
50612a8
Compare
| // daemon streaming several commands can hit this from several goroutines at | ||
| // once. Whoever arrives second sees the token has already moved and retries | ||
| // without reading again. | ||
| func (c *Client) reload(used string) bool { |
There was a problem hiding this comment.
reload() acquires the write lock before calling c.reloadToken(), which reads from disk or the OS keychain. While that lock is held, every goroutine calling c.token() (read lock) blocks - including all concurrent in-flight streaming connections in the daemon. The collapse logic is correct, but the I/O can happen outside the lock:
func (c *Client) reload(used string) bool {
if c.reloadToken == nil {
return false
}
c.mu.RLock()
cur := c.authToken
c.mu.RUnlock()
if cur != used {
return true
}
tok, err := c.reloadToken() // I/O outside the lock
if err != nil || tok == "" || tok == used {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
if c.authToken != used {
return true // lost the race
}
c.authToken = tok
return true
| // daemon streaming several commands can hit this from several goroutines at | ||
| // once. Whoever arrives second sees the token has already moved and retries | ||
| // without reading again. | ||
| func (c *Client) reload(used string) bool { |
There was a problem hiding this comment.
reload() acquires the write lock before calling c.reloadToken(), which reads from disk or the OS keychain. While that lock is held, every goroutine calling c.token() (read lock) blocks - including all concurrent in-flight streaming connections in the daemon. The collapse logic is correct, but the I/O can happen outside the lock:
func (c *Client) reload(used string) bool {
if c.reloadToken == nil {
return false
}
c.mu.RLock()
cur := c.authToken
c.mu.RUnlock()
if cur != used {
return true
}
tok, err := c.reloadToken() // I/O outside the lock
if err != nil || tok == "" || tok == used {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
if c.authToken != used {
return true // lost the race
}
c.authToken = tok
return true
2 of 4 — splitting #548. Base:
main— #551 has landed, and this is rebased on #543 (theinternal/tui→internal/uicollapse).Summary
The watch daemon owns a registered command's output stream, so output outlives the process that submitted it — for a hook-driven run, that process exits immediately.
chunk validate --remoteandchunk sidecar execboth register through one helper._daemonsubcommand and passed intoRunDaemon, rather than resolved lazily inside the daemon behind a mutex.Carries the fixes for all four review threads on #548.
Credential resolution
The first cut resolved the client lazily inside the daemon, behind a mutex, retrying at most once every 30s. The daemon is detached with no controlling terminal, so it can never approve a keychain prompt — lazy or eager, an unauthenticated machine gets
ErrNeedsAuthand no output either way. The machinery only bought cost: a lock wrapping keychain I/O on the registration path, in front of a hook the developer is waiting on, under a 2sRegisterCommandtimeout.Resolution now happens once, up front, and the client (which may be nil) is passed in. Nothing new was needed to support the unauthenticated case —
outputStore.registeralready treats a nilstreamFnas "record the command, stream nothing", andSnapshot.AuthErroralready carries the reason. With no credentials mutex left,snapshotreadsauthErrorunder the existing project lock and the lock-ordering note goes away.Resolving once means a daemon holds its client — nil included — for its whole life, so
chunk auth loginwould otherwise have no effect on one already running.EnsureRunningreuses a reachable daemon of the same build, so re-runningchunk watchwould not have helped either. Storing a CircleCI token now stops a running daemon (watchd.StopForCredentialChange, best-effort and silent): achunk watchalready on screen relaunches it throughEnsureLaunchedon its next poll, and otherwise the nextchunk watchstarts one that can authenticate.Token reload on 401 (FACT-460)
A client captured its token at construction and kept it for life, so a process outliving a login never saw the new credential. On a 401 the client now re-reads the stored credential and retries once if it changed. Opt-in via
httpcl.Config.ReloadToken, threaded throughcircleci.Config, wired inauthprompt.ResolveCircleCIClient. Since the SSE stream goes throughCall,StreamOutputinherits it.Deliberately a reload, not a refresh:
internal/oauthrequestsgrant_type=authorization_codeonly, stores no refresh token, and discardsexpires_in, so there is no grant to call. This covers "logged in elsewhere, this process holds a stale token"; it cannot cover "token aged out with nobody re-authenticating".Review notes:
ValidateCircleCITokendeliberately does not setReloadToken: it validates a token the user just typed, and reloading could "validate" a different stored one.ResolveCircleCIClientget this. The GitHub, Anthropic and token-validation clients are unchanged.securitysubprocess when the token came from the keychain, worst case the 3skeyring.Gettimeout), and the retry shares the first attempt's deadline, so a late 401 leaves the retry little room.Test plan
task test,task lint,task buildAuthErrorsurfacedchunk auth loginwith achunk watchon screen: daemon is replaced and output starts streaming