From e94a4cbce5751b528018573cf4fd2d329e013982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 28 May 2026 19:17:46 +0200 Subject: [PATCH] session-extend: preempt previous WaitExtendAuthSession on new wait When the tray "Extend now" notification action and the about-to-expire dialog both start a flow for the same deadline, the daemon was running two independent IdP polls and the older one surfaced an InvalidArgument toast as soon as the second RequestExtend overwrote the pending flow. Follow the WaitSSOLogin pattern: at the top of WaitExtendAuthSession cancel the previous wait (the SetWaitCancel/CancelWait pair on PendingFlow already existed but was unused), then register the new wait's cancel. Preempted callers exit with codes.Canceled; the authsession service translates that into ExtendResult{Preempted: true} so the tray and the React dialog can stay silent on the losing flow instead of showing a false-failure toast / error dialog. --- client/server/server.go | 15 ++++++++++++++- client/ui/authsession/service.go | 11 ++++++++++- .../session/SessionAboutToExpireDialog.tsx | 12 +++++++++++- client/ui/tray.go | 12 ++++++++++-- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/client/server/server.go b/client/server/server.go index d84941023..de24d7faa 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1539,8 +1539,21 @@ func (s *Server) WaitExtendAuthSession( return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow") } - tokenInfo, err := oAuthFlow.WaitToken(ctx, authInfo) + // Preempt a previous WaitExtendAuthSession (e.g. when the tray + // notification and the about-to-expire dialog both start a flow on + // the same deadline). The older waiter exits via context.Canceled; + // the new one takes over the IdP poll. + s.extendAuthSessionFlow.CancelWait() + + waitCtx, cancel := context.WithCancel(ctx) + defer cancel() + s.extendAuthSessionFlow.SetWaitCancel(cancel) + + tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo) if err != nil { + if errors.Is(err, context.Canceled) { + return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted") + } return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err) } diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go index 8cb423da2..5cc96aca4 100644 --- a/client/ui/authsession/service.go +++ b/client/ui/authsession/service.go @@ -6,6 +6,9 @@ import ( "context" "time" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/proto" ) @@ -34,9 +37,12 @@ type ExtendWaitParams struct { // ExtendResult carries the refreshed deadline. ExpiresAt is nil when the // management server reported the peer is not eligible for session -// extension. +// extension. Preempted is true when a newer WaitExtend (e.g. started from +// another UI surface for the same deadline) took over the IdP poll — +// callers should treat the call as a no-op rather than a failure. type ExtendResult struct { ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` + Preempted bool `json:"preempted,omitempty"` } // DaemonConn yields a lazy daemon gRPC client. Mirrors services.DaemonConn @@ -101,6 +107,9 @@ func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendRes UserCode: p.UserCode, }) if err != nil { + if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Canceled { + return ExtendResult{Preempted: true}, nil + } return ExtendResult{}, err } diff --git a/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx b/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx index 1d07b0988..63ba4d2eb 100644 --- a/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx @@ -73,10 +73,20 @@ export default function SessionAboutToExpireDialog() { console.debug("OpenURL failed during extend", e); } } - await Session.WaitExtend({ + const result = await Session.WaitExtend({ deviceCode: start.deviceCode, userCode: start.userCode, }); + if (result.preempted) { + // Another UI surface (e.g. the tray "Extend now" + // notification action) started a flow for the same + // deadline and took over. Keep the dialog open so the + // user can re-trigger if the other flow also fails; + // a successful extend elsewhere refreshes the deadline + // and this window auto-closes when it's no longer + // relevant. + return; + } WindowManager.CloseSessionAboutToExpire().catch(console.error); } catch (e) { await Dialogs.Error({ diff --git a/client/ui/tray.go b/client/ui/tray.go index 5d1114db4..c677052ba 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -1391,14 +1391,22 @@ func (t *Tray) runExtendSession() { } } - if _, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{ + result, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{ DeviceCode: start.DeviceCode, UserCode: start.UserCode, - }); err != nil { + }) + if err != nil { log.Warnf("session-warning: WaitExtend failed: %v", err) t.notifyError(t.loc.T("notify.sessionWarning.failed")) return } + if result.Preempted { + // Another UI surface (e.g. the about-to-expire dialog) started a + // flow for the same deadline and took over. Stay silent so the + // user only sees the outcome of the surviving flow. + log.Debugf("session-warning: WaitExtend preempted by a newer flow") + return + } t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning) }