Repository navigation
fix(manager): a login ends only once its service started or its dir is gone #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,9 +24,10 @@ const loginWait = 90 * time.Second | |
| // server's WriteTimeout, which is shorter than a login. | ||
| const loginWriteTimeout = loginWait + 15*time.Second | ||
|
|
||
| // abortWait bounds how long an abort waits for the killed login child to be | ||
| // reaped and its half-made instance dir removed. | ||
| const abortWait = 10 * time.Second | ||
| // abortWait bounds how long an abort waits for the login to end: the killed | ||
| // child reaped and its half-made instance dir removed, or, for a child that | ||
| // had already logged in, the account's service started. | ||
| const abortWait = liteReadyTimeout + 15*time.Second | ||
|
|
||
| // errLoginCanceled ends a login whose client went away or that a new login of | ||
| // the same account replaced. | ||
|
|
@@ -68,8 +69,9 @@ type LoginState struct { | |
| aborted bool | ||
|
|
||
| done chan error // nil => success; error => failure | ||
| // exited is closed once runLoginChild has returned: the child is reaped | ||
| // and a failed login's instance dir is gone. | ||
| // exited is closed once the login has ended: the child is reaped, and a | ||
| // failed login's instance dir is gone or a successful one's service has | ||
| // started. | ||
| exited chan struct{} | ||
| } | ||
|
|
||
|
|
@@ -124,7 +126,8 @@ func releaseLogin(id string, st *LoginState) bool { | |
|
|
||
| // abortLogin kills an in-flight login's child and waits, up to abortWait, | ||
| // until runLoginChild has reaped it and removed its instance dir. A child | ||
| // that has already finished the login is left to finish. | ||
| // that has already logged in is not undone: the wait then lasts until the | ||
| // account's service has started. | ||
| func abortLogin(st *LoginState) { | ||
| st.mu.Lock() | ||
| st.aborted = true | ||
|
|
@@ -136,7 +139,7 @@ func abortLogin(st *LoginState) { | |
| select { | ||
| case <-st.exited: | ||
| case <-time.After(abortWait): | ||
| log.Warnf("[wrapper %s] the canceled login's child did not exit within %s", shortID(st.Id), abortWait) | ||
| log.Warnf("[wrapper %s] the canceled login did not end within %s", shortID(st.Id), abortWait) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -233,6 +236,10 @@ func startLogin(ctx context.Context, req LoginRequest) LoginResult { | |
| if old := getLogin(id); old != nil { | ||
| log.Infof("[wrapper %s] a new login replaces the one in flight", shortID(id)) | ||
| abortLogin(old) | ||
| // The old one may have logged in before it was stopped. | ||
| if inst := GetInstance(id); inst != nil && inst.Region != "" { | ||
| return LoginResult{Code: -1, Msg: "already login", LoginId: id} | ||
| } | ||
| } | ||
| st, ok := reserveLogin(id) | ||
| if !ok { | ||
|
|
@@ -273,8 +280,13 @@ func runLoginChild(id, username, password string, st *LoginState) { | |
| for { | ||
| select { | ||
| case werr := <-waitCh: | ||
| // Child exited. Token files present => success; else classify. | ||
| if st.isAborted() { | ||
| // Child exited and reaped: its pid may be reused, so an abort | ||
| // from now on kills nothing. | ||
| st.mu.Lock() | ||
| st.cmd = nil | ||
| st.mu.Unlock() | ||
| // Token files present => success; else classify. | ||
| if st.isAborted() && !hasTokenFiles(id) { | ||
| loginFailed(id, st, errLoginCanceled) | ||
| return | ||
| } | ||
|
Comment on lines
+289
to
292
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Cancellation can leave an account running If tokens exist when a request is canceled, the service can still start after the request reports cancellation. Callers that retry can receive (Refers to this code) Was this helpful? React with 👍 or 👎 to provide feedback.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documented in the README: a cancel after the child logged in does not undo the login, and a retry answers already login. |
||
|
|
@@ -316,9 +328,10 @@ func classifyLoginFailure(werr error) error { | |
| // waitForLoginFinish blocks until the in-flight login resolves: the child | ||
| // exits (success/failure), or 2FA becomes required. When 2FA is required it | ||
| // is reported once (code 2); a second call (the client resubmits with a code) | ||
| // blocks until the child exits with the final outcome. When ctx ends or | ||
| // loginWait passes first, the login is aborted: its child is killed and its | ||
| // instance dir removed before this returns, so a retry starts clean. | ||
| // blocks until the child exits with the final outcome. When ctx ends first, | ||
| // the login is aborted: its child is killed and its instance dir removed | ||
| // before this returns, so a retry starts clean. When loginWait passes first, | ||
| // the abort runs on after the answer, and a retry waits for it. | ||
| func waitForLoginFinish(ctx context.Context, id string, st *LoginState) LoginResult { | ||
| timeout := time.NewTimer(loginWait) | ||
| defer timeout.Stop() | ||
|
|
@@ -337,7 +350,10 @@ func waitForLoginFinish(ctx context.Context, id string, st *LoginState) LoginRes | |
| abortLogin(st) | ||
| return LoginResult{Code: -1, Msg: errLoginCanceled.Error(), LoginId: id} | ||
| case <-timeout.C: | ||
| abortLogin(st) | ||
| // The answer must beat the write deadline, and the abort may | ||
| // wait for a service that is starting: it ends on its own, and a | ||
| // retry waits for it (startLogin). | ||
| go abortLogin(st) | ||
| return LoginResult{Code: -1, Msg: "login timeout", LoginId: id} | ||
| case <-tick.C: | ||
| if st.claimPending2FA() { | ||
|
|
@@ -390,24 +406,23 @@ func provide2FACode(id string, code string) error { | |
|
|
||
| // finishLoginSuccess is called when tokens are on disk: it starts the service | ||
| // process and, once the instance reports ready, resolves the login. username | ||
| // is recorded as the instance's Name. | ||
| // is recorded as the instance's Name. It runs within runLoginChild, so the | ||
| // login has not ended (exited) until the service has started. | ||
| func finishLoginSuccess(id, username string, st *LoginState) { | ||
| go func() { | ||
| syscall.Sync() // the tokens the child wrote, before anything counts on them | ||
| instance := getInstanceOrNew(id) | ||
| instance.NoRestart = false | ||
| instance.Name = username | ||
| if err := startLiteService(instance); err != nil { | ||
| log.Errorf("[wrapper %s] failed to start after login: %v", shortID(id), err) | ||
| loginFailed(id, st, fmt.Errorf("failed to start service: %w", err)) | ||
| return | ||
| } | ||
| if countReady() >= ShouldStartInstances { | ||
| setReady(true) | ||
| } | ||
| SaveInstances() | ||
| resolveLogin(id, st, nil) | ||
| }() | ||
| syscall.Sync() // the tokens the child wrote, before anything counts on them | ||
| instance := getInstanceOrNew(id) | ||
| instance.NoRestart = false | ||
| instance.Name = username | ||
| if err := startLiteService(instance); err != nil { | ||
| log.Errorf("[wrapper %s] failed to start after login: %v", shortID(id), err) | ||
| loginFailed(id, st, fmt.Errorf("failed to start service: %w", err)) | ||
| return | ||
| } | ||
| if countReady() >= ShouldStartInstances { | ||
| setReady(true) | ||
| } | ||
| SaveInstances() | ||
| resolveLogin(id, st, nil) | ||
| } | ||
|
|
||
| // resolveLogin completes the pending login. | ||
|
|
@@ -423,9 +438,15 @@ func resolveLogin(id string, st *LoginState, cause error) { | |
| // an error. | ||
| func loginFailed(id string, st *LoginState, cause error) { | ||
| log.Warnf("[wrapper %s] login failed: %v", shortID(id), cause) | ||
| if releaseLogin(id, st) { | ||
| // The dir goes while st still holds the account, under the registry's | ||
| // lock: a retry cannot reserve it and start writing there in between, | ||
| // and a login that replaced st keeps its dir. | ||
| loginRegistry.Lock() | ||
| if loginRegistry.m[id] == st { | ||
| _ = RemoveWrapperDataQuiet(id) | ||
| delete(loginRegistry.m, id) | ||
| } | ||
| loginRegistry.Unlock() | ||
| resolveLogin(id, st, cause) | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Login timeout response misses write deadline
If service startup is pending at 90 seconds,
abortLogincan wait another 75 seconds. The login response then exceedshandleLogin's 105-second write deadline, so the client receives no timeout answer.Learn more
A login request has a 90-second timer, and its HTTP handler gives the response a 105-second write deadline handleLogin. After the timer fires, waitForLoginFinish calls
abortLoginsynchronously. If the login child exited with tokens but the service is still starting,st.cmdis nil andabortLoginwaits for service readiness rather than stopping it. That wait can extend past the HTTP deadline even though the service is operating within its own 60-second readiness window.Example: The child finishes at 85 seconds, and the service becomes ready 30 seconds later. The login timer fires at 90 seconds; the handler cannot write its timeout response at 115 seconds because its write deadline was 105 seconds.
Recommended fix: Bound the HTTP handler's remaining wait by its write deadline, or separate cancellation response timing from the service-startup wait without permitting a retry to wipe the starting instance's directory. Test a child completing near
loginWaitwith a service taking longer thanloginWriteTimeout - loginWaitto become ready.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: the timeout answers first and the abort runs after it; a retry waits for it.