golang.org/x/tools/gopls@v0.15.3/internal/server/prompt.go (about)

     1  // Copyright 2023 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package server
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"os"
    11  	"path/filepath"
    12  	"time"
    13  
    14  	"golang.org/x/tools/gopls/internal/protocol"
    15  	"golang.org/x/tools/gopls/internal/telemetry"
    16  	"golang.org/x/tools/internal/event"
    17  )
    18  
    19  // promptTimeout is the amount of time we wait for an ongoing prompt before
    20  // prompting again. This gives the user time to reply. However, at some point
    21  // we must assume that the client is not displaying the prompt, the user is
    22  // ignoring it, or the prompt has been disrupted in some way (e.g. by a gopls
    23  // crash).
    24  const promptTimeout = 24 * time.Hour
    25  
    26  // The following constants are used for testing telemetry integration.
    27  const (
    28  	TelemetryPromptWorkTitle    = "Checking telemetry prompt"     // progress notification title, for awaiting in tests
    29  	GoplsConfigDirEnvvar        = "GOPLS_CONFIG_DIR"              // overridden for testing
    30  	FakeTelemetryModefileEnvvar = "GOPLS_FAKE_TELEMETRY_MODEFILE" // overridden for testing
    31  	TelemetryYes                = "Yes, I'd like to help."
    32  	TelemetryNo                 = "No, thanks."
    33  )
    34  
    35  // getenv returns the effective environment variable value for the provided
    36  // key, looking up the key in the session environment before falling back on
    37  // the process environment.
    38  func (s *server) getenv(key string) string {
    39  	if v, ok := s.Options().Env[key]; ok {
    40  		return v
    41  	}
    42  	return os.Getenv(key)
    43  }
    44  
    45  // configDir returns the root of the gopls configuration dir. By default this
    46  // is os.UserConfigDir/gopls, but it may be overridden for tests.
    47  func (s *server) configDir() (string, error) {
    48  	if d := s.getenv(GoplsConfigDirEnvvar); d != "" {
    49  		return d, nil
    50  	}
    51  	userDir, err := os.UserConfigDir()
    52  	if err != nil {
    53  		return "", err
    54  	}
    55  	return filepath.Join(userDir, "gopls"), nil
    56  }
    57  
    58  // telemetryMode returns the current effective telemetry mode.
    59  // By default this is x/telemetry.Mode(), but it may be overridden for tests.
    60  func (s *server) telemetryMode() string {
    61  	if fake := s.getenv(FakeTelemetryModefileEnvvar); fake != "" {
    62  		if data, err := os.ReadFile(fake); err == nil {
    63  			return string(data)
    64  		}
    65  		return "local"
    66  	}
    67  	return telemetry.Mode()
    68  }
    69  
    70  // setTelemetryMode sets the current telemetry mode.
    71  // By default this calls x/telemetry.SetMode, but it may be overridden for
    72  // tests.
    73  func (s *server) setTelemetryMode(mode string) error {
    74  	if fake := s.getenv(FakeTelemetryModefileEnvvar); fake != "" {
    75  		return os.WriteFile(fake, []byte(mode), 0666)
    76  	}
    77  	return telemetry.SetMode(mode)
    78  }
    79  
    80  // maybePromptForTelemetry checks for the right conditions, and then prompts
    81  // the user to ask if they want to enable Go telemetry uploading. If the user
    82  // responds 'Yes', the telemetry mode is set to "on".
    83  //
    84  // The actual conditions for prompting are defensive, erring on the side of not
    85  // prompting.
    86  // If enabled is false, this will not prompt the user in any condition,
    87  // but will send work progress reports to help testing.
    88  func (s *server) maybePromptForTelemetry(ctx context.Context, enabled bool) {
    89  	if s.Options().VerboseWorkDoneProgress {
    90  		work := s.progress.Start(ctx, TelemetryPromptWorkTitle, "Checking if gopls should prompt about telemetry...", nil, nil)
    91  		defer work.End(ctx, "Done.")
    92  	}
    93  
    94  	if !enabled { // check this after the work progress message for testing.
    95  		return // prompt is disabled
    96  	}
    97  
    98  	if s.telemetryMode() == "on" || s.telemetryMode() == "off" {
    99  		// Telemetry is already on or explicitly off -- nothing to ask about.
   100  		return
   101  	}
   102  
   103  	errorf := func(format string, args ...any) {
   104  		err := fmt.Errorf(format, args...)
   105  		event.Error(ctx, "telemetry prompt failed", err)
   106  	}
   107  
   108  	// Only prompt if we can read/write the prompt config file.
   109  	configDir, err := s.configDir()
   110  	if err != nil {
   111  		errorf("unable to determine config dir: %v", err)
   112  		return
   113  	}
   114  
   115  	var (
   116  		promptDir  = filepath.Join(configDir, "prompt")    // prompt configuration directory
   117  		promptFile = filepath.Join(promptDir, "telemetry") // telemetry prompt file
   118  	)
   119  
   120  	// prompt states, to be written to the prompt file
   121  	const (
   122  		pYes     = "yes"     // user said yes
   123  		pNo      = "no"      // user said no
   124  		pPending = "pending" // current prompt is still pending
   125  		pFailed  = "failed"  // prompt was asked but failed
   126  	)
   127  	validStates := map[string]bool{
   128  		pYes:     true,
   129  		pNo:      true,
   130  		pPending: true,
   131  		pFailed:  true,
   132  	}
   133  
   134  	// parse the current prompt file
   135  	var (
   136  		state    string
   137  		attempts = 0 // number of times we've asked already
   138  	)
   139  	if content, err := os.ReadFile(promptFile); err == nil {
   140  		if _, err := fmt.Sscanf(string(content), "%s %d", &state, &attempts); err == nil && validStates[state] {
   141  			if state == pYes || state == pNo {
   142  				// Prompt has been answered. Nothing to do.
   143  				return
   144  			}
   145  		} else {
   146  			state, attempts = "", 0
   147  			errorf("malformed prompt result %q", string(content))
   148  		}
   149  	} else if !os.IsNotExist(err) {
   150  		errorf("reading prompt file: %v", err)
   151  		// Something went wrong. Since we don't know how many times we've asked the
   152  		// prompt, err on the side of not spamming.
   153  		return
   154  	}
   155  
   156  	if attempts >= 5 {
   157  		// We've tried asking enough; give up.
   158  		return
   159  	}
   160  	if attempts == 0 {
   161  		// First time asking the prompt; we may need to make the prompt dir.
   162  		if err := os.MkdirAll(promptDir, 0777); err != nil {
   163  			errorf("creating prompt dir: %v", err)
   164  			return
   165  		}
   166  	}
   167  
   168  	// Acquire the lock and write "pending" to the prompt file before actually
   169  	// prompting.
   170  	//
   171  	// This ensures that the prompt file is writeable, and that we increment the
   172  	// attempt counter before we prompt, so that we don't end up in a failure
   173  	// mode where we keep prompting and then failing to record the response.
   174  
   175  	release, ok, err := acquireLockFile(promptFile)
   176  	if err != nil {
   177  		errorf("acquiring prompt: %v", err)
   178  		return
   179  	}
   180  	if !ok {
   181  		// Another prompt is currently pending.
   182  		return
   183  	}
   184  	defer release()
   185  
   186  	attempts++
   187  
   188  	pendingContent := []byte(fmt.Sprintf("%s %d", pPending, attempts))
   189  	if err := os.WriteFile(promptFile, pendingContent, 0666); err != nil {
   190  		errorf("writing pending state: %v", err)
   191  		return
   192  	}
   193  
   194  	var prompt = `Go telemetry helps us improve Go by periodically sending anonymous metrics and crash reports to the Go team. Learn more at https://go.dev/doc/telemetry.
   195  
   196  Would you like to enable Go telemetry?
   197  `
   198  	if s.Options().LinkifyShowMessage {
   199  		prompt = `Go telemetry helps us improve Go by periodically sending anonymous metrics and crash reports to the Go team. Learn more at [go.dev/doc/telemetry](https://go.dev/doc/telemetry).
   200  
   201  Would you like to enable Go telemetry?
   202  `
   203  	}
   204  	// TODO(rfindley): investigate a "tell me more" action in combination with ShowDocument.
   205  	params := &protocol.ShowMessageRequestParams{
   206  		Type:    protocol.Info,
   207  		Message: prompt,
   208  		Actions: []protocol.MessageActionItem{
   209  			{Title: TelemetryYes},
   210  			{Title: TelemetryNo},
   211  		},
   212  	}
   213  
   214  	item, err := s.client.ShowMessageRequest(ctx, params)
   215  	if err != nil {
   216  		errorf("ShowMessageRequest failed: %v", err)
   217  		// Defensive: ensure item == nil for the logic below.
   218  		item = nil
   219  	}
   220  
   221  	message := func(typ protocol.MessageType, msg string) {
   222  		if !showMessage(ctx, s.client, typ, msg) {
   223  			// Make sure we record that "telemetry prompt failed".
   224  			errorf("showMessage failed: %v", err)
   225  		}
   226  	}
   227  
   228  	result := pFailed
   229  	if item == nil {
   230  		// e.g. dialog was dismissed
   231  		errorf("no response")
   232  	} else {
   233  		// Response matches MessageActionItem.Title.
   234  		switch item.Title {
   235  		case TelemetryYes:
   236  			result = pYes
   237  			if err := s.setTelemetryMode("on"); err == nil {
   238  				message(protocol.Info, telemetryOnMessage(s.Options().LinkifyShowMessage))
   239  			} else {
   240  				errorf("enabling telemetry failed: %v", err)
   241  				msg := fmt.Sprintf("Failed to enable Go telemetry: %v\nTo enable telemetry manually, please run `go run golang.org/x/telemetry/cmd/gotelemetry@latest on`", err)
   242  				message(protocol.Error, msg)
   243  			}
   244  
   245  		case TelemetryNo:
   246  			result = pNo
   247  		default:
   248  			errorf("unrecognized response %q", item.Title)
   249  			message(protocol.Error, fmt.Sprintf("Unrecognized response %q", item.Title))
   250  		}
   251  	}
   252  	resultContent := []byte(fmt.Sprintf("%s %d", result, attempts))
   253  	if err := os.WriteFile(promptFile, resultContent, 0666); err != nil {
   254  		errorf("error writing result state to prompt file: %v", err)
   255  	}
   256  }
   257  
   258  func telemetryOnMessage(linkify bool) string {
   259  	format := `Thank you. Telemetry uploading is now enabled.
   260  
   261  To disable telemetry uploading, run %s.
   262  `
   263  	var runCmd = "`go run golang.org/x/telemetry/cmd/gotelemetry@latest local`"
   264  	if linkify {
   265  		runCmd = "[gotelemetry local](https://golang.org/x/telemetry/cmd/gotelemetry)"
   266  	}
   267  	return fmt.Sprintf(format, runCmd)
   268  }
   269  
   270  // acquireLockFile attempts to "acquire a lock" for writing to path.
   271  //
   272  // This is achieved by creating an exclusive lock file at <path>.lock. Lock
   273  // files expire after a period, at which point acquireLockFile will remove and
   274  // recreate the lock file.
   275  //
   276  // acquireLockFile fails if path is in a directory that doesn't exist.
   277  func acquireLockFile(path string) (func(), bool, error) {
   278  	lockpath := path + ".lock"
   279  	fi, err := os.Stat(lockpath)
   280  	if err == nil {
   281  		if time.Since(fi.ModTime()) > promptTimeout {
   282  			_ = os.Remove(lockpath) // ignore error
   283  		} else {
   284  			return nil, false, nil
   285  		}
   286  	} else if !os.IsNotExist(err) {
   287  		return nil, false, fmt.Errorf("statting lockfile: %v", err)
   288  	}
   289  
   290  	f, err := os.OpenFile(lockpath, os.O_CREATE|os.O_EXCL, 0666)
   291  	if err != nil {
   292  		if os.IsExist(err) {
   293  			return nil, false, nil
   294  		}
   295  		return nil, false, fmt.Errorf("creating lockfile: %v", err)
   296  	}
   297  	fi, err = f.Stat()
   298  	if err != nil {
   299  		return nil, false, err
   300  	}
   301  	release := func() {
   302  		_ = f.Close() // ignore error
   303  		fi2, err := os.Stat(lockpath)
   304  		if err == nil && os.SameFile(fi, fi2) {
   305  			// Only clean up the lockfile if it's the same file we created.
   306  			// Otherwise, our lock has expired and something else has the lock.
   307  			//
   308  			// There's a race here, in that the file could have changed since the
   309  			// stat above; but given that we've already waited 24h this is extremely
   310  			// unlikely, and acceptable.
   311  			_ = os.Remove(lockpath)
   312  		}
   313  	}
   314  	return release, true, nil
   315  }