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 }