From 53b2fb8dc139b95d08b02ae111039fa6040ccc0f Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 13 May 2026 15:51:36 +0200 Subject: [PATCH] [client/ui] Add async Up mode to avoid blocking profile switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon's Up RPC previously always blocked in waitForUp (up to 50s) until the engine connected. The UI does not need this — status updates already flow through the SubscribeStatus stream. Add bool async = 4 to UpRequest. When true the daemon starts connectWithRetryRuns and returns immediately; the CLI path (async=false, the default) is unchanged. ProfileSwitcher.SwitchActive now sets Async:true so all three RPCs (Status, Switch, Down, Up) return quickly. The background goroutine and its associated race condition are removed entirely. --- client/proto/daemon.pb.go | 26 +++++++++++++++++----- client/proto/daemon.proto | 6 +++++ client/server/server.go | 3 +++ client/ui/services/connection.go | 7 +++++- client/ui/services/profileswitcher.go | 32 +++++++++++++-------------- 5 files changed, 51 insertions(+), 23 deletions(-) diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 789d7c2b2..3d54f5735 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v7.34.1 +// protoc v3.21.12 // source: daemon.proto package proto @@ -823,9 +823,15 @@ func (x *WaitSSOLoginResponse) GetEmail() string { } type UpRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` - Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` + Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + Async bool `protobuf:"varint,4,opt,name=async,proto3" json:"async,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -874,6 +880,13 @@ func (x *UpRequest) GetUsername() string { return "" } +func (x *UpRequest) GetAsync() bool { + if x != nil { + return x.Async + } + return false +} + type UpResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -6309,10 +6322,11 @@ const file_daemon_proto_rawDesc = "" + "\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" + "\bhostname\x18\x02 \x01(\tR\bhostname\",\n" + "\x14WaitSSOLoginResponse\x12\x14\n" + - "\x05email\x18\x01 \x01(\tR\x05email\"v\n" + + "\x05email\x18\x01 \x01(\tR\x05email\"\x8c\x01\n" + "\tUpRequest\x12%\n" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + - "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12\x14\n" + + "\x05async\x18\x04 \x01(\bR\x05asyncB\x0e\n" + "\f_profileNameB\v\n" + "\t_usernameJ\x04\b\x03\x10\x04\"\f\n" + "\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 1676f255e..aa7b121a0 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -233,6 +233,12 @@ message UpRequest { optional string profileName = 1; optional string username = 2; reserved 3; + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + bool async = 4; } message UpResponse {} diff --git a/client/server/server.go b/client/server/server.go index 38fdd0010..0bc6358e3 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -747,6 +747,9 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) s.mutex.Unlock() + if msg.GetAsync() { + return &proto.UpResponse{}, nil + } return s.waitForUp(callerCtx) } diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index 84b4652ba..ada90d621 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -42,6 +42,11 @@ type WaitSSOParams struct { type UpParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` + // Async instructs the daemon to start the connection and return + // immediately. Status updates flow via the SubscribeStatus stream. + // When false (the default) the RPC blocks until connected, which is + // the CLI behaviour. + Async bool `json:"async"` } // LogoutParams selects the profile the daemon should log out. @@ -147,7 +152,7 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error { if err != nil { return err } - req := &proto.UpRequest{} + req := &proto.UpRequest{Async: p.Async} if p.ProfileName != "" { req.ProfileName = ptrStr(p.ProfileName) } diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go index a63364ec7..12d92101f 100644 --- a/client/ui/services/profileswitcher.go +++ b/client/ui/services/profileswitcher.go @@ -37,8 +37,9 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, peers *Peers } // SwitchActive switches to the named profile applying the reconnect policy. -// It returns after the Switch RPC completes so the caller can refresh its UI -// immediately; Down and Up run in a background goroutine. +// All RPCs complete quickly: Up uses async mode so the daemon starts the +// connection attempt and returns immediately; status updates flow via the +// SubscribeStatus stream. func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error { prevStatus := "" if st, err := s.peers.Get(ctx); err == nil { @@ -61,22 +62,21 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error return fmt.Errorf("switch profile %q: %w", p.ProfileName, err) } - go func() { - bgCtx := context.Background() - if needsDown { - if err := s.connection.Down(bgCtx); err != nil { - log.Errorf("profileswitcher: Down: %v", err) - } + if needsDown { + if err := s.connection.Down(ctx); err != nil { + log.Errorf("profileswitcher: Down: %v", err) } - if wasActive { - if err := s.connection.Up(bgCtx, UpParams{ - ProfileName: p.ProfileName, - Username: p.Username, - }); err != nil { - log.Errorf("profileswitcher: Up %s: %v", p.ProfileName, err) - } + } + + if wasActive { + if err := s.connection.Up(ctx, UpParams{ + ProfileName: p.ProfileName, + Username: p.Username, + Async: true, + }); err != nil { + return fmt.Errorf("reconnect %q: %w", p.ProfileName, err) } - }() + } return nil }