From 88970897bfe167b41a73e90f3871d08e08236802 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 10 Apr 2026 04:53:33 +0000 Subject: [PATCH 1/7] feat: add lightspeed commands to skip update check for fast exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extensions using AzureDeveloperCLICredential spawn 'azd auth token' as a subprocess with a 10-second timeout. The background update check goroutine (60s timeout) was blocking process exit in main.go, causing 'signal: killed' errors when the update check was slow (e.g., after laptop wake, DNS stalls). This change adds a Lightspeed property to ActionDescriptorOptions that marks commands requiring fast exit. The update check goroutine is now started inside ExecuteWithAutoInstall after command identification — lightspeed commands skip it entirely, so the process exits immediately after producing output. Changes: - Add Lightspeed bool field to ActionDescriptorOptions - Propagate lightspeed to cobra annotations in CobraBuilder - Move fetchLatestVersion from main.go into startUpdateCheck() in auto_install.go - Start update check only after rootCmd.Find(), skip for lightspeed commands - Return ExecuteResult from ExecuteWithAutoInstall with LatestVersion channel - Mark 'auth token' as Lightspeed: true Fixes #7360 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/actions/action_descriptor.go | 5 + cli/azd/cmd/auth.go | 1 + cli/azd/cmd/auto_install.go | 134 ++++++++++++++++--- cli/azd/cmd/auto_install_integration_test.go | 46 ++++++- cli/azd/cmd/cobra_builder.go | 9 ++ cli/azd/cmd/cobra_builder_test.go | 38 ++++++ cli/azd/main.go | 61 +++------ 7 files changed, 225 insertions(+), 69 deletions(-) diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index d6b0550b7a6..71ac26585ae 100644 --- a/cli/azd/cmd/actions/action_descriptor.go +++ b/cli/azd/cmd/actions/action_descriptor.go @@ -195,6 +195,11 @@ type ActionDescriptorOptions struct { GroupingOptions CommandGroupOptions // Whether or not the command requires a principal login RequireLogin bool + // Whether the command should execute as fast as possible, skipping + // non-essential background work like update checks. + // Use for programmatic/hidden commands (e.g., auth token) where + // blocking process exit is unacceptable. + Lightspeed bool } // Completion function used for cobra command flag completion diff --git a/cli/azd/cmd/auth.go b/cli/azd/cmd/auth.go index edc7c160ccf..49043930756 100644 --- a/cli/azd/cmd/auth.go +++ b/cli/azd/cmd/auth.go @@ -26,6 +26,7 @@ func authActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { ActionResolver: newAuthTokenAction, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, DefaultFormat: output.NoneFormat, + Lightspeed: true, }) group.Add("login", &actions.ActionDescriptorOptions{ diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 73b0f698f3b..376570aa05c 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -13,16 +13,19 @@ import ( "slices" "strconv" "strings" + "time" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/update" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -385,14 +388,74 @@ func tryAutoInstallExtension( return true, nil } +// startUpdateCheck launches a background goroutine that checks for a newer +// version of azd and returns a channel that will receive the result. +// The caller should read from the returned channel after command execution. +func startUpdateCheck() <-chan *update.VersionInfo { + ch := make(chan *update.VersionInfo, 1) + + // Allow the user to skip the update check by setting AZD_SKIP_UPDATE_CHECK. + if value, has := os.LookupEnv("AZD_SKIP_UPDATE_CHECK"); has { + if setting, err := strconv.ParseBool(value); err == nil && setting { + log.Print("skipping update check since AZD_SKIP_UPDATE_CHECK is true") + close(ch) + return ch + } + } + + bgCtx, bgCancel := context.WithTimeout(context.Background(), 60*time.Second) + + go func() { + defer close(ch) + defer bgCancel() + + configMgr := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) + userConfig, err := configMgr.Load() + if err != nil { + userConfig = config.NewEmptyConfig() + } + + cfg := update.LoadUpdateConfig(userConfig) + + mgr := update.NewManager(nil, nil) + versionInfo, err := mgr.CheckForUpdate(bgCtx, cfg, false) + if err != nil { + log.Printf("failed to check for updates: %v, skipping update check", err) + return + } + + ch <- versionInfo + }() + + return ch +} + +// ExecuteResult holds the outcome of ExecuteWithAutoInstall, including +// metadata about the executed command that callers may need for post-execution +// decisions (e.g., whether to wait for the background update check). +type ExecuteResult struct { + // Err is the error returned by the command execution, if any. + Err error + // IsLightspeed is true when the executed command was marked as Lightspeed. + // Callers should skip non-essential post-execution work (update checks, banners) + // for lightspeed commands so the process can exit quickly. + IsLightspeed bool + // LatestVersion receives the result of the background update check. + // Nil when the update check was skipped (lightspeed commands, AZD_SKIP_UPDATE_CHECK). + LatestVersion <-chan *update.VersionInfo +} + // ExecuteWithAutoInstall executes the command and handles auto-installation of extensions for unknown commands. -func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) error { +func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) *ExecuteResult { + result := &ExecuteResult{} + // Parse global flags BEFORE creating the command tree. // This allows us to access flag values (like --no-prompt, --debug) early for auto-install logic. // This also enables the global options to be set in the container for support during extension framework callbacks. globalOpts := &internal.GlobalCommandOptions{} if err := ParseGlobalFlags(os.Args[1:], globalOpts); err != nil { - return fmt.Errorf("Warning: failed to parse global flags: %w", err) + result.Err = fmt.Errorf("Warning: failed to parse global flags: %w", err) + return result } // Register GlobalCommandOptions as a singleton in the container BEFORE building the command tree. @@ -411,13 +474,26 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // This allows us to determine if a subcommand was provided or not or if the command is unknown. foundCmd, originalArgs, err := rootCmd.Find(os.Args[1:]) if err == nil { + // Detect lightspeed commands from the cobra annotation set by CobraBuilder. + result.IsLightspeed = foundCmd.Annotations["lightspeed"] == "true" + + // Start the background update check AFTER command identification. + // Lightspeed commands (e.g., auth token) skip the update check entirely + // so the process can exit as fast as possible — this prevents the + // AzureDeveloperCLICredential 10-second subprocess timeout from being + // hit when the update check is slow (laptop wake, DNS stalls, etc.). + if !result.IsLightspeed { + result.LatestVersion = startUpdateCheck() + } + // Check for partial namespace match (e.g., "ai" found but "ai.agent" not installed) if installed := tryAutoInstallForPartialNamespace( ctx, rootContainer, foundCmd, originalArgs, ); installed { // Extension was installed, rebuild command tree and execute rootCmd = NewRootCmd(false, nil, rootContainer) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } // Known command, proceed with normal execution @@ -427,7 +503,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Other command errors (for example, unsupported output formats) should be returned directly. unsupportedErr, ok := errors.AsType[*project.UnsupportedServiceHostError](err) if !ok { - return err + result.Err = err + return result } if err := rootContainer.Resolve(&extensionManager); err != nil { @@ -446,7 +523,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Do not fail if we couldn't check for extensions - just proceed to normal execution log.Println("Error: check for extensions. Skipping auto-install:", err) console.Message(ctx, unsupportedErr.ErrorMessage) - return nil + return result } // Note: We don't need to filter or check which extensions are installed. // If any of these extensions would be installed, the auto-install wouldn't have been triggered because @@ -454,7 +531,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if len(availableExtensionsForHost) == 0 { // did not find an extension with the capability, just print the original error message console.Message(ctx, unsupportedErr.ErrorMessage) - return nil + return result } console.Message(ctx, @@ -470,7 +547,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai chosenExtension, err := promptForExtensionChoice(ctx, console, availableExtensionsForHost) if err != nil { console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - return err + result.Err = err + return result } extensionIdToInstall = *chosenExtension } @@ -479,18 +557,24 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if installErr != nil { // Error needs to be printed here or else it will be hidden b/c the error printing is handled inside runtime console.Message(ctx, installErr.Error()) - return installErr + result.Err = installErr + return result } if installed { // Extension was installed, build command tree and execute rootCmd := NewRootCmd(false, nil, rootContainer) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } - return err + result.Err = err + return result } + // Unknown command path — always start the update check since these aren't lightspeed. + result.LatestVersion = startUpdateCheck() + // Extract flags that take values from the root command flagsWithValues := extractFlagsWithValues(rootCmd) @@ -502,7 +586,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Check if this is a built-in command first (includes core commands and installed extensions) if isBuiltInCommand(rootCmd, unknownCommand) { // This is a built-in command, proceed with normal execution without checking for extensions - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } if err := rootContainer.Resolve(&extensionManager); err != nil { @@ -516,12 +601,14 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if unknownCommand == "login" { console.Message(ctx, "Error: The 'azd login' command has been removed.") console.Message(ctx, "Please use 'azd auth login' instead.") - return fmt.Errorf("unknown command 'login'") + result.Err = fmt.Errorf("unknown command 'login'") + return result } if unknownCommand == "logout" { console.Message(ctx, "Error: The 'azd logout' command has been removed.") console.Message(ctx, "Please use 'azd auth logout' instead.") - return fmt.Errorf("unknown command 'logout'") + result.Err = fmt.Errorf("unknown command 'logout'") + return result } // If unknown flags were found before a non-built-in command, return an error with helpful guidance @@ -540,7 +627,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai unknownCommand) console.Message(ctx, errorMsg) - return fmt.Errorf("unknown flags before command: %s", flagsList) + result.Err = fmt.Errorf("unknown flags before command: %s", flagsList) + return result } // Get all remaining arguments starting from the command for namespace matching @@ -563,7 +651,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if err != nil { // Do not fail if we couldn't check for extensions - just proceed to normal execution log.Println("Error: check for extensions. Skipping auto-install:", err) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } if len(extensionMatches) > 0 { @@ -580,12 +669,14 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai chosenExtension, err := promptForExtensionChoice(ctx, console, extensionMatches) if err != nil { console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } if chosenExtension == nil { // User cancelled selection, proceed to normal execution - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } // Try to auto-install the chosen extension @@ -593,19 +684,22 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if installErr != nil { // Error needs to be printed here or else it will be hidden b/c the error printing is handled inside runtime console.Message(ctx, installErr.Error()) - return installErr + result.Err = installErr + return result } if installed { // Extension was installed, build command tree and execute rootCmd := NewRootCmd(false, nil, rootContainer) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } } } // Normal execution path - either no args, no matching extension, or user declined install - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } // CreateGlobalFlagSet creates a new flag set with all global flags defined. diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index e72f9811e38..f1476c0e0ed 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -111,9 +111,9 @@ func TestExecuteWithAutoInstall_ReturnsCommandErrorWithoutPanicForOutputFlags(t os.Stderr = originalStderr }() - var execErr error + var execResult *ExecuteResult require.NotPanics(t, func() { - execErr = ExecuteWithAutoInstall(t.Context(), rootContainer) + execResult = ExecuteWithAutoInstall(t.Context(), rootContainer) }) require.NoError(t, stderrWriter.Close()) @@ -121,9 +121,49 @@ func TestExecuteWithAutoInstall_ReturnsCommandErrorWithoutPanicForOutputFlags(t stderrBytes, err := io.ReadAll(stderrReader) require.NoError(t, err) - require.ErrorContains(t, execErr, "unsupported format 'unknown'") + require.ErrorContains(t, execResult.Err, "unsupported format 'unknown'") require.NotContains(t, string(stderrBytes), "panic:") require.Contains(t, string(stderrBytes), "Error: unsupported format 'unknown'") + + // auth token is a lightspeed command — verify the flag is set even when the command fails + require.True(t, execResult.IsLightspeed, "auth token should be detected as a lightspeed command") +} + +func TestExecuteWithAutoInstall_LightspeedDetection(t *testing.T) { + originalArgs := os.Args + defer func() { + os.Args = originalArgs + }() + + clearAgentEnvVarsForTest(t) + agentdetect.ResetDetection() + defer agentdetect.ResetDetection() + + tests := []struct { + name string + args []string + expectLightspeed bool + }{ + { + name: "auth_token_is_lightspeed", + args: []string{"azd", "auth", "token", "--output", "json"}, + expectLightspeed: true, + }, + { + name: "version_is_not_lightspeed", + args: []string{"azd", "version"}, + expectLightspeed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Args = tt.args + rootContainer := ioc.NewNestedContainer(nil) + result := ExecuteWithAutoInstall(t.Context(), rootContainer) + require.Equal(t, tt.expectLightspeed, result.IsLightspeed) + }) + } } // TestAgentDetectionIntegration tests the full agent detection integration flow. diff --git a/cli/azd/cmd/cobra_builder.go b/cli/azd/cmd/cobra_builder.go index 0aaadc09857..a0c963ea244 100644 --- a/cli/azd/cmd/cobra_builder.go +++ b/cli/azd/cmd/cobra_builder.go @@ -297,6 +297,15 @@ func (cb *CobraBuilder) bindCommand(cmd *cobra.Command, descriptor *actions.Acti actions.SetGroupCommandAnnotation(cmd, descriptor.Options.GroupingOptions.RootLevelHelp) } + // Propagate lightspeed flag to cobra annotations so it can be detected + // after rootCmd.Find() without needing access to the ActionDescriptor. + if descriptor.Options != nil && descriptor.Options.Lightspeed { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations["lightspeed"] = "true" + } + // `generateCmdHelp` sets a default help section when `descriptor.Options.HelpOptions` is nil. // This call ensures all commands gets the same help formatting. cmd.SetHelpTemplate(generateCmdHelp(cmd, generateCmdHelpOptions{ diff --git a/cli/azd/cmd/cobra_builder_test.go b/cli/azd/cmd/cobra_builder_test.go index 2cf6663ea5a..dde0a7fae00 100644 --- a/cli/azd/cmd/cobra_builder_test.go +++ b/cli/azd/cmd/cobra_builder_test.go @@ -381,3 +381,41 @@ func (m *testMiddlewareB) Run(ctx context.Context, nextFn middleware.NextFn) (*a return nextFn(ctx) } + +func Test_LightspeedAnnotation(t *testing.T) { + t.Parallel() + + container := ioc.NewNestedContainer(nil) + ioc.RegisterInstance(container, &internal.GlobalCommandOptions{}) + builder := NewCobraBuilder(container) + + root := actions.NewActionDescriptor("root", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{Use: "root"}, + }) + + // Add a lightspeed child command + root.Add("fast", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{Use: "fast"}, + ActionResolver: newTestAction, + Lightspeed: true, + }) + + // Add a normal child command + root.Add("normal", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{Use: "normal"}, + ActionResolver: newTestAction, + }) + + rootCmd, err := builder.BuildCommand(root) + require.NoError(t, err) + + // Verify lightspeed annotation is set on the fast command + fastCmd, _, err := rootCmd.Find([]string{"fast"}) + require.NoError(t, err) + require.Equal(t, "true", fastCmd.Annotations["lightspeed"]) + + // Verify lightspeed annotation is NOT set on the normal command + normalCmd, _, err := rootCmd.Find([]string{"normal"}) + require.NoError(t, err) + require.NotEqual(t, "true", normalCmd.Annotations["lightspeed"]) +} diff --git a/cli/azd/main.go b/cli/azd/main.go index 38bdacb1820..7d1d732bf08 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -60,10 +60,6 @@ func main() { showedElevationWarning := false - latest := make(chan *update.VersionInfo) - bgCtx, bgCancel := context.WithTimeout(context.Background(), 60*time.Second) - go fetchLatestVersion(bgCtx, latest) - rootContainer := ioc.NewNestedContainer(nil) ctx = context.WithoutCancel(ctx) @@ -72,8 +68,11 @@ func main() { // Register the context for singleton resolution ioc.RegisterInstance(rootContainer, ctx) - // Execute command with auto-installation support for extensions - cmdErr := cmd.ExecuteWithAutoInstall(ctx, rootContainer) + // Execute command with auto-installation support for extensions. + // The update check goroutine is started inside ExecuteWithAutoInstall, + // after the command is identified — lightspeed commands skip it entirely. + execResult := cmd.ExecuteWithAutoInstall(ctx, rootContainer) + cmdErr := execResult.Err oneauth.Shutdown() @@ -83,8 +82,15 @@ func main() { } } - versionInfo, ok := <-latest - bgCancel() + // Wait for the background update check if one was started. + // For lightspeed commands, LatestVersion is nil (no goroutine was started). + var versionInfo *update.VersionInfo + if execResult.LatestVersion != nil { + v, ok := <-execResult.LatestVersion + if ok { + versionInfo = v + } + } // If we were able to fetch a latest version, check to see if we are up to date and // print a warning if we are not. Non-production builds (dev builds via `go install` and @@ -92,7 +98,7 @@ func main() { // // Don't write this message when JSON output is enabled, since in that case we use stderr to return structured // information about command progress. - if !isJsonOutput() && ok && !suppressUpdateBanner() && !showedElevationWarning { + if versionInfo != nil && !isJsonOutput() && !suppressUpdateBanner() && !showedElevationWarning { if internal.IsNonProdVersion() { log.Printf("eliding update message for non-production build") } else if versionInfo.HasUpdate { @@ -148,43 +154,6 @@ func main() { } } -// fetchLatestVersion checks for a newer version of the CLI using the user's -// configured channel and sends the result across the channel, which it then closes. -// If the latest version can not be determined, the channel is closed without writing a value. -func fetchLatestVersion(ctx context.Context, result chan<- *update.VersionInfo) { - defer close(result) - - // Allow the user to skip the update check if they wish, by setting AZD_SKIP_UPDATE_CHECK to - // a truthy value. - if value, has := os.LookupEnv("AZD_SKIP_UPDATE_CHECK"); has { - if setting, err := strconv.ParseBool(value); err == nil && setting { - log.Print("skipping update check since AZD_SKIP_UPDATE_CHECK is true") - return - } else if err != nil { - log.Printf("could not parse value for AZD_SKIP_UPDATE_CHECK a boolean "+ - "(it was: %s), proceeding with update check", value) - } - } - - // Load user config to determine channel - configMgr := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) - userConfig, err := configMgr.Load() - if err != nil { - userConfig = config.NewEmptyConfig() - } - - cfg := update.LoadUpdateConfig(userConfig) - - mgr := update.NewManager(nil, nil) - versionInfo, err := mgr.CheckForUpdate(ctx, cfg, false) - if err != nil { - log.Printf("failed to check for updates: %v, skipping update check", err) - return - } - - result <- versionInfo -} - // isDebugEnabled checks to see if `--debug` was passed with a truthy // value. func isDebugEnabled() bool { From 1b7de95e400aa6141c2cb15ff11b51339d2a7a83 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 10 Apr 2026 04:56:50 +0000 Subject: [PATCH 2/7] chore: add lightspeed to cspell dictionary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 32e0aaf77d3..4f6b9d2896c 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -5,6 +5,7 @@ words: - Authenticode - gofmt - golangci + - lightspeed - toplevel - azcloud - azdext From caed3b284cfcc73db332aa0e2ce77972e16b893e Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 10 Apr 2026 05:03:09 +0000 Subject: [PATCH 3/7] fix: address PR review feedback - Restore parse-error log for invalid AZD_SKIP_UPDATE_CHECK values - Accept context parameter in startUpdateCheck for tracing/cancellation - Set AZD_SKIP_UPDATE_CHECK in test to avoid real HTTP calls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/auto_install.go | 11 +++++++---- cli/azd/cmd/auto_install_integration_test.go | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 376570aa05c..a6e417182fb 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -391,7 +391,7 @@ func tryAutoInstallExtension( // startUpdateCheck launches a background goroutine that checks for a newer // version of azd and returns a channel that will receive the result. // The caller should read from the returned channel after command execution. -func startUpdateCheck() <-chan *update.VersionInfo { +func startUpdateCheck(ctx context.Context) <-chan *update.VersionInfo { ch := make(chan *update.VersionInfo, 1) // Allow the user to skip the update check by setting AZD_SKIP_UPDATE_CHECK. @@ -400,10 +400,13 @@ func startUpdateCheck() <-chan *update.VersionInfo { log.Print("skipping update check since AZD_SKIP_UPDATE_CHECK is true") close(ch) return ch + } else if err != nil { + log.Printf("could not parse value for AZD_SKIP_UPDATE_CHECK as a boolean "+ + "(it was: %s), proceeding with update check", value) } } - bgCtx, bgCancel := context.WithTimeout(context.Background(), 60*time.Second) + bgCtx, bgCancel := context.WithTimeout(ctx, 60*time.Second) go func() { defer close(ch) @@ -483,7 +486,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // AzureDeveloperCLICredential 10-second subprocess timeout from being // hit when the update check is slow (laptop wake, DNS stalls, etc.). if !result.IsLightspeed { - result.LatestVersion = startUpdateCheck() + result.LatestVersion = startUpdateCheck(ctx) } // Check for partial namespace match (e.g., "ai" found but "ai.agent" not installed) @@ -573,7 +576,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai } // Unknown command path — always start the update check since these aren't lightspeed. - result.LatestVersion = startUpdateCheck() + result.LatestVersion = startUpdateCheck(ctx) // Extract flags that take values from the root command flagsWithValues := extractFlagsWithValues(rootCmd) diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index f1476c0e0ed..10e2e0c7c61 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -158,6 +158,8 @@ func TestExecuteWithAutoInstall_LightspeedDetection(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Prevent non-lightspeed commands from making real HTTP update check calls. + t.Setenv("AZD_SKIP_UPDATE_CHECK", "true") os.Args = tt.args rootContainer := ioc.NewNestedContainer(nil) result := ExecuteWithAutoInstall(t.Context(), rootContainer) From 0c67522c1ff7c3957b5697f7490730d7155ca8db Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 10 Apr 2026 05:13:26 +0000 Subject: [PATCH 4/7] fix: correct LatestVersion doc comment to match behavior The channel is nil only for lightspeed commands (goroutine never starts). When AZD_SKIP_UPDATE_CHECK skips the check, the channel is closed without a value rather than being nil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/auto_install.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index a6e417182fb..fd2e3230964 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -444,7 +444,9 @@ type ExecuteResult struct { // for lightspeed commands so the process can exit quickly. IsLightspeed bool // LatestVersion receives the result of the background update check. - // Nil when the update check was skipped (lightspeed commands, AZD_SKIP_UPDATE_CHECK). + // Nil when the update check was not started (lightspeed commands). + // When the check is skipped via AZD_SKIP_UPDATE_CHECK, the returned channel + // is closed without a value. LatestVersion <-chan *update.VersionInfo } From f3643d1477a390d82f8f12e02cd436811486072e Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 10 Apr 2026 05:21:30 +0000 Subject: [PATCH 5/7] fix: avoid real credential stack in lightspeed detection test Use --output unknown to fail fast before touching the credential/provider stack. The test only needs to verify IsLightspeed detection, not actual token acquisition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/auto_install_integration_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index 10e2e0c7c61..25406f18a77 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -145,8 +145,10 @@ func TestExecuteWithAutoInstall_LightspeedDetection(t *testing.T) { expectLightspeed bool }{ { - name: "auth_token_is_lightspeed", - args: []string{"azd", "auth", "token", "--output", "json"}, + name: "auth_token_is_lightspeed", + // Use --output unknown to fail before touching the credential stack. + // We only care about IsLightspeed detection, not actual token acquisition. + args: []string{"azd", "auth", "token", "--output", "unknown"}, expectLightspeed: true, }, { From a0c54b8c4b7c2e0e1efd0d4ffb6c6eb01cd3bedd Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 10 Apr 2026 05:30:42 +0000 Subject: [PATCH 6/7] fix: assert LatestVersion nil/non-nil in lightspeed test Directly test the core behavioral change: lightspeed commands must have LatestVersion == nil (no goroutine started), non-lightspeed commands must have LatestVersion != nil (goroutine started, even when AZD_SKIP_UPDATE_CHECK short-circuits it). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/auto_install_integration_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index 25406f18a77..55f8b758d29 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -166,6 +166,17 @@ func TestExecuteWithAutoInstall_LightspeedDetection(t *testing.T) { rootContainer := ioc.NewNestedContainer(nil) result := ExecuteWithAutoInstall(t.Context(), rootContainer) require.Equal(t, tt.expectLightspeed, result.IsLightspeed) + + if tt.expectLightspeed { + // Lightspeed commands must NOT start the update check goroutine. + require.Nil(t, result.LatestVersion, + "lightspeed commands should not start the update check") + } else { + // Non-lightspeed commands should always start the update check + // (even when AZD_SKIP_UPDATE_CHECK short-circuits it). + require.NotNil(t, result.LatestVersion, + "non-lightspeed commands should start the update check") + } }) } } From c93ca1f55d00437047d5671070d15939ac52bd03 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 10 Apr 2026 05:39:42 +0000 Subject: [PATCH 7/7] refactor: extract lightspeed annotation key to shared constant Define AnnotationLightspeed = "azd.lightspeed" in cmd/actions package and reference it from cobra_builder.go, auto_install.go, and tests. Uses azd. namespace prefix to avoid collisions with extension annotations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/actions/action_descriptor.go | 6 ++++++ cli/azd/cmd/auto_install.go | 3 ++- cli/azd/cmd/cobra_builder.go | 2 +- cli/azd/cmd/cobra_builder_test.go | 4 ++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index 71ac26585ae..9b8807a0d70 100644 --- a/cli/azd/cmd/actions/action_descriptor.go +++ b/cli/azd/cmd/actions/action_descriptor.go @@ -159,6 +159,12 @@ type commandGroupAnnotationKey string const ( // cmdGrouperKey is an annotation key that is added as part of a cobra annotations for assigning commands to a group. cmdGrouperKey commandGroupAnnotationKey = "commandGrouper" + + // AnnotationLightspeed is a cobra annotation key that marks a command as lightspeed. + // Lightspeed commands skip non-essential background work (update checks) so the + // process can exit as fast as possible. Set automatically by CobraBuilder when + // ActionDescriptorOptions.Lightspeed is true. + AnnotationLightspeed = "azd.lightspeed" ) // GetGroupCommandAnnotation check if there is a grouping annotation for the command. Returns the annotation value as an diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index fd2e3230964..e9cf5d82e3c 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" @@ -480,7 +481,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai foundCmd, originalArgs, err := rootCmd.Find(os.Args[1:]) if err == nil { // Detect lightspeed commands from the cobra annotation set by CobraBuilder. - result.IsLightspeed = foundCmd.Annotations["lightspeed"] == "true" + result.IsLightspeed = foundCmd.Annotations[actions.AnnotationLightspeed] == "true" // Start the background update check AFTER command identification. // Lightspeed commands (e.g., auth token) skip the update check entirely diff --git a/cli/azd/cmd/cobra_builder.go b/cli/azd/cmd/cobra_builder.go index a0c963ea244..89f9843e03c 100644 --- a/cli/azd/cmd/cobra_builder.go +++ b/cli/azd/cmd/cobra_builder.go @@ -303,7 +303,7 @@ func (cb *CobraBuilder) bindCommand(cmd *cobra.Command, descriptor *actions.Acti if cmd.Annotations == nil { cmd.Annotations = make(map[string]string) } - cmd.Annotations["lightspeed"] = "true" + cmd.Annotations[actions.AnnotationLightspeed] = "true" } // `generateCmdHelp` sets a default help section when `descriptor.Options.HelpOptions` is nil. diff --git a/cli/azd/cmd/cobra_builder_test.go b/cli/azd/cmd/cobra_builder_test.go index dde0a7fae00..2e724f3e186 100644 --- a/cli/azd/cmd/cobra_builder_test.go +++ b/cli/azd/cmd/cobra_builder_test.go @@ -412,10 +412,10 @@ func Test_LightspeedAnnotation(t *testing.T) { // Verify lightspeed annotation is set on the fast command fastCmd, _, err := rootCmd.Find([]string{"fast"}) require.NoError(t, err) - require.Equal(t, "true", fastCmd.Annotations["lightspeed"]) + require.Equal(t, "true", fastCmd.Annotations[actions.AnnotationLightspeed]) // Verify lightspeed annotation is NOT set on the normal command normalCmd, _, err := rootCmd.Find([]string{"normal"}) require.NoError(t, err) - require.NotEqual(t, "true", normalCmd.Annotations["lightspeed"]) + require.NotEqual(t, "true", normalCmd.Annotations[actions.AnnotationLightspeed]) }