diff --git a/go.mod b/go.mod index 7b523bd..8561ca2 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/net v0.45.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.36.0 golang.org/x/term v0.35.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.7.0 // indirect diff --git a/internal/agent/m3/m3.go b/internal/agent/m3/m3.go index c8e4336..5ca30cc 100644 --- a/internal/agent/m3/m3.go +++ b/internal/agent/m3/m3.go @@ -250,16 +250,6 @@ func (m3 *M3App) captureAndTransmit(pids map[int]string, endpoint string) { } } - // Eagerly resolve the .NET helper path once per cycle when any .NET - // targets exist, so failures surface early rather than per-capture. - if len(dotnetPIDs) > 0 { - if resolved, err := config.ResolveDotnetToolPath(); err != nil { - logger.Warn().Err(err).Msg(".NET helper not found - .NET captures will fail") - } else if config.GlobalConfig.DotnetToolPath == "" { - config.GlobalConfig.DotnetToolPath = resolved - } - } - if m3.AsyncDotNetGCCapture != nil { // Reconcile creates/updates async GC capture sessions for current .NET PIDs. // In the per-PID loop below, uploadDotnetGCM3 reads and uploads artifacts from diff --git a/internal/agent/ondemand/ondemand.go b/internal/agent/ondemand/ondemand.go index 7c7eb91..7a522b3 100644 --- a/internal/agent/ondemand/ondemand.go +++ b/internal/agent/ondemand/ondemand.go @@ -289,13 +289,6 @@ Ignored errors: %v appRuntime := config.GetAppRuntime(pid) if appRuntime == "dotnet" { - // Eagerly resolve the .NET helper path so failures surface early, - // before any capture goroutines are launched. - if resolved, err := config.ResolveDotnetToolPath(); err != nil { - logger.Warn().Err(err).Msg(".NET helper not found - .NET captures will fail") - } else if config.GlobalConfig.DotnetToolPath == "" { - config.GlobalConfig.DotnetToolPath = resolved - } // ------------------------------------------------------------------------------ // .NET runtime captures // ------------------------------------------------------------------------------ diff --git a/internal/capture/dotnet_gc.go b/internal/capture/dotnet_gc.go index c23c8c1..c88dae4 100644 --- a/internal/capture/dotnet_gc.go +++ b/internal/capture/dotnet_gc.go @@ -96,7 +96,7 @@ func (d *DotnetGC) CaptureToFile() (*os.File, error) { } // Execute the dotnet tool and capture output - file, err := executeDotnetTool(args, fmt.Sprintf(dotnetGCOutputPath, d.Pid)) + file, err := executeDotnetTool(d.Pid, args, fmt.Sprintf(dotnetGCOutputPath, d.Pid)) if err != nil { return nil, fmt.Errorf("failed to capture .NET GC events: %w", err) } diff --git a/internal/capture/dotnet_gc_async.go b/internal/capture/dotnet_gc_async.go index ac9ad87..a4b3502 100644 --- a/internal/capture/dotnet_gc_async.go +++ b/internal/capture/dotnet_gc_async.go @@ -121,7 +121,7 @@ func (d *DotnetGCAsync) ensureStartedLocked(pid int, appName string) error { } args := []string{"-gc", strconv.Itoa(pid), d.baseDir, "-1"} - cmd, err := startDotnetToolInBackground(args, executils.DirHooker{Dir: d.baseDir}) + cmd, err := startDotnetToolInBackground(pid, args, executils.DirHooker{Dir: d.baseDir}) if err != nil { return err } diff --git a/internal/capture/dotnet_heap.go b/internal/capture/dotnet_heap.go index efe6252..0b7d83f 100644 --- a/internal/capture/dotnet_heap.go +++ b/internal/capture/dotnet_heap.go @@ -49,7 +49,7 @@ func (d *DotnetHeap) CaptureToFile() (*os.File, error) { } // Execute the dotnet tool and capture output - file, err := executeDotnetTool(args, fmt.Sprintf(dotnetHeapOutputPath, d.Pid)) + file, err := executeDotnetTool(d.Pid, args, fmt.Sprintf(dotnetHeapOutputPath, d.Pid)) if err != nil { return nil, fmt.Errorf("failed to capture .NET heap statistics: %w", err) } diff --git a/internal/capture/dotnet_thread.go b/internal/capture/dotnet_thread.go index 7cbfeed..b937ce1 100644 --- a/internal/capture/dotnet_thread.go +++ b/internal/capture/dotnet_thread.go @@ -49,7 +49,7 @@ func (d *DotnetThread) CaptureToFile() (*os.File, error) { } // Execute the dotnet tool and capture output - file, err := executeDotnetTool(args, fmt.Sprintf(dotnetThreadOutputPath, d.Pid)) + file, err := executeDotnetTool(d.Pid, args, fmt.Sprintf(dotnetThreadOutputPath, d.Pid)) if err != nil { return nil, fmt.Errorf("failed to capture .NET thread dump: %w", err) } diff --git a/internal/capture/dotnet_tool_selector_others.go b/internal/capture/dotnet_tool_selector_others.go new file mode 100644 index 0000000..122138a --- /dev/null +++ b/internal/capture/dotnet_tool_selector_others.go @@ -0,0 +1,10 @@ +//go:build !windows + +package capture + +func detectTargetArch(pid int) (string, error) { + // pid is unused on non-Windows; + // kept to match the Windows signature, discarded here to silence unusedparams lint + _ = pid + return "", nil +} diff --git a/internal/capture/dotnet_tool_selector_windows.go b/internal/capture/dotnet_tool_selector_windows.go new file mode 100644 index 0000000..c69a8ec --- /dev/null +++ b/internal/capture/dotnet_tool_selector_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package capture + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func detectTargetArch(pid int) (string, error) { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return "", err + } + defer windows.CloseHandle(h) + + var targetWow64 bool + if err = windows.IsWow64Process(h, &targetWow64); err != nil { + return "", err + } + + // On 32-bit Windows, everything is x86. + if isOS64Bit() { + if targetWow64 { + return "x86", nil + } + return "x64", nil + } + return "x86", nil +} + +func isOS64Bit() bool { + return os.Getenv("PROCESSOR_ARCHITEW6432") != "" || os.Getenv("PROCESSOR_ARCHITECTURE") == "AMD64" +} diff --git a/internal/capture/dotnet_utils.go b/internal/capture/dotnet_utils.go index 17471ea..3b7e457 100644 --- a/internal/capture/dotnet_utils.go +++ b/internal/capture/dotnet_utils.go @@ -26,6 +26,15 @@ var knownDotnetToolErrors = []dotnetToolFriendlyError{ }, } +const DotnetSourceUserOverride = "user-override" +const DotnetSourceArchMatched = "arch-matched" +const DotnetSourceDefault = "default" + +type DotnetToolResolution struct { + Path string + Source string // "user-override", "arch-matched", "default" +} + // wrapDotnetToolStartError wraps a command-start error, appending a // user-friendly message when the error matches a known pattern. The original // error message is always preserved for debugging. @@ -39,30 +48,61 @@ func wrapDotnetToolStartError(err error, cmdArgs []string) error { return fmt.Errorf("failed to start dotnet tool %v: %w", cmdArgs, err) } -// ensureDotnetToolResolved lazily resolves DotnetToolPath if it was not set -// during validation (e.g. when runtime was auto-detected rather than explicit). -func ensureDotnetToolResolved() (string, error) { - if path := config.GlobalConfig.DotnetToolPath; path != "" { - return path, nil +func resolveDotnetToolForPid(pid int) (DotnetToolResolution, error) { + // user override + if config.GlobalConfig.DotnetToolPath != "" { + resolvedPath, err := config.ResolveDotnetToolOverride() + if err != nil { + return DotnetToolResolution{}, err + } + return DotnetToolResolution{Path: resolvedPath, Source: DotnetSourceUserOverride}, nil } - resolved, err := config.ResolveDotnetToolPath() - if err != nil { - return "", err + + // arch matched + arch, detectErr := detectTargetArch(pid) + if detectErr != nil { + logger.Warn().Err(detectErr).Int("pid", pid).Msg("could not detect target arch") + } + if arch != "" { + name := config.DotnetToolNameForArch(arch) + if p, ok := config.FindDotnetToolNearYcOrPath(name); ok { + return DotnetToolResolution{Path: p, Source: DotnetSourceArchMatched}, nil + } + + return DotnetToolResolution{}, fmt.Errorf(".NET tool for PID %d (%s) not found. expected %s next to yc or on PATH", pid, arch, name) + } + + // No arch info — fall back to default tool name + if p, ok := config.FindDotnetToolNearYcOrPath(config.DefaultDotnetToolName); ok { + if detectErr != nil { + logger.Warn(). + Err(detectErr). + Int("pid", pid). + Str("path", p). + Msg("using legacy .NET tool path because target arch detection failed") + } + return DotnetToolResolution{Path: p, Source: DotnetSourceDefault}, nil + } + + if detectErr != nil { + return DotnetToolResolution{}, fmt.Errorf( + ".NET tool path %q not found near yc or on PATH (target arch detection for PID %d failed: %w)", + config.DefaultDotnetToolName, pid, detectErr) } - config.GlobalConfig.DotnetToolPath = resolved - return resolved, nil + return DotnetToolResolution{}, fmt.Errorf( + ".NET tool path %q not found near yc or on PATH", config.DefaultDotnetToolName) } -// executeDotnetTool runs the configured .NET helper executable with the given arguments +// executeDotnetTool runs the configured .NET tool executable with the given arguments // and captures the output to a file. Returns the file handle and any error. -func executeDotnetTool(args []string, outputPath string) (*os.File, error) { - toolPath, err := ensureDotnetToolResolved() +func executeDotnetTool(pid int, args []string, outputPath string) (*os.File, error) { + toolResolution, err := resolveDotnetToolForPid(pid) if err != nil { return nil, err } // Build the command: [toolPath, args...] - cmdArgs := append([]string{toolPath}, args...) + cmdArgs := append([]string{toolResolution.Path}, args...) logger.Log("Executing dotnet tool: %v", cmdArgs) @@ -144,15 +184,15 @@ func executeDotnetTool(args []string, outputPath string) (*os.File, error) { return file, nil } -// startDotnetToolInBackground starts the configured .NET helper executable with the +// startDotnetToolInBackground starts the configured .NET tool executable with the // given arguments and returns the running command handle without waiting. -func startDotnetToolInBackground(args []string, hookers ...executils.Hooker) (executils.CmdManager, error) { - toolPath, err := ensureDotnetToolResolved() +func startDotnetToolInBackground(pid int, args []string, hookers ...executils.Hooker) (executils.CmdManager, error) { + toolResolution, err := resolveDotnetToolForPid(pid) if err != nil { return nil, err } - cmdArgs := append([]string{toolPath}, args...) + cmdArgs := append([]string{toolResolution.Path}, args...) logger.Log("Starting dotnet tool in background: %v", cmdArgs) cmd, err := executils.CommandStartInBackground(cmdArgs, hookers...) diff --git a/internal/cli/validation.go b/internal/cli/validation.go index 2758c46..052e8cf 100644 --- a/internal/cli/validation.go +++ b/internal/cli/validation.go @@ -45,12 +45,21 @@ func validate() error { // .NET tool validation if runtime.GOOS == "windows" && appRuntime == "dotnet" { - toolPath, err := config.ResolveDotnetToolPath() - if err != nil { - logger.Error().Msgf("%v", err) - return ErrInvalidArgumentCantContinue + if config.GlobalConfig.DotnetToolPath != "" { + if err := config.ValidateDotnetToolOverride(); err != nil { + logger.Error().Msgf("%v", err) + return ErrInvalidArgumentCantContinue + } + } else { + warnings, err := config.ValidateDotnetToolInstall() + if err != nil { + logger.Error().Msgf("%v", err) + return ErrInvalidArgumentCantContinue + } + for _, w := range warnings { + logger.Warn().Msg(w) + } } - config.GlobalConfig.DotnetToolPath = toolPath } // M3 diff --git a/internal/config/config.go b/internal/config/config.go index 60b1ac4..acc45b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -111,11 +111,15 @@ type Options struct { // Dotnet runtime support AppRuntime string `yaml:"appRuntime" usage:"Override target application runtime: java or dotnet. Default is auto-detect"` - DotnetToolPath string `yaml:"dotnetToolPath" usage:"Optional path to the .NET helper executable. If empty, yc will look for yc-dot-net.exe next to the yc binary"` + DotnetToolPath string `yaml:"dotnetToolPath" usage:"Optional path to the .NET tool executable. If empty, yc will look for yc-dot-net-x86.exe and yc-dot-net-x64.exe next to the yc binary"` GcDuration uint `yaml:"gcDuration" usage:"duration for .Net GC capture in seconds"` } -const DefaultDotnetToolName = "yc-dot-net.exe" +const ( + DefaultDotnetToolName = "yc-dot-net.exe" // legacy, no-arch-specific + DotnetToolNameX86 = "yc-dot-net-x86.exe" + DotnetToolNameX64 = "yc-dot-net-x64.exe" +) type Command struct { UrlParams UrlParams `yaml:"urlParams" usage:"[DEPRECATED] This option is no longer in use."` @@ -521,34 +525,73 @@ func GetConfiguredAppRuntime() string { return NormalizeAppRuntime(GlobalConfig.AppRuntime) } -func ResolveDotnetToolPath() (string, error) { - return resolveDotnetToolPath(GlobalConfig.DotnetToolPath) +func DotnetToolNameForArch(arch string) string { + switch arch { + case "x86": + return DotnetToolNameX86 + default: + return DotnetToolNameX64 + } } -func resolveDotnetToolPath(configured string) (string, error) { - configured = strings.TrimSpace(configured) - if configured != "" { - resolved, err := exec.LookPath(configured) - if err != nil { - return "", fmt.Errorf(".NET tool [%s] not found at the specified path %q. Please ensure the path includes the .NET executable [%s]", - DefaultDotnetToolName, configured, DefaultDotnetToolName) - } - return resolved, nil +// ResolveDotnetToolOverride resolves the user-supplied executable exactly as an executable path/name +// (absolute path, relative path, or PATH lookup). +func ResolveDotnetToolOverride() (string, error) { + configured := strings.TrimSpace(GlobalConfig.DotnetToolPath) + if configured == "" { + return "", fmt.Errorf("-dotnetToolPath is not set") + } + resolved, err := exec.LookPath(configured) + if err != nil { + return "", fmt.Errorf(".NET tool specified by -dotnetToolPath %q not found: %w", configured, err) + } + return resolved, nil +} + +// ValidateDotnetToolOverride verifies the user-supplied dotnet tool is resolvable. +func ValidateDotnetToolOverride() error { + _, err := ResolveDotnetToolOverride() + return err +} + +// ValidateDotnetToolInstall validates the installation of dotnet tools, both archs. +// Pass if both arch-specific tools are present. +// Fail if none found, Warn if one arch-specific tool is missing. +func ValidateDotnetToolInstall() (warnings []string, err error) { + _, x86Found := FindDotnetToolNearYcOrPath(DotnetToolNameX86) + _, x64Found := FindDotnetToolNearYcOrPath(DotnetToolNameX64) + + switch { + case !x86Found && !x64Found: + return nil, fmt.Errorf( + "no .NET tool binaries found (%s, %s) next to the yc executable or on PATH", + DotnetToolNameX86, DotnetToolNameX64, + ) + case !x86Found: + return []string{ + fmt.Sprintf("%s not found - captures of x86 .NET targets will fail", DotnetToolNameX86), + }, nil + case !x64Found: + return []string{ + fmt.Sprintf("%s not found - captures of x64 .NET targets will fail", DotnetToolNameX64), + }, nil } + return nil, nil +} - exePath, err := os.Executable() - if err == nil { - candidate := filepath.Join(filepath.Dir(exePath), DefaultDotnetToolName) +// FindDotnetToolNearYcOrPath looks for a dotnet tool binary next to the yc executable +// first, then on PATH. +func FindDotnetToolNearYcOrPath(toolName string) (string, bool) { + if exePath, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exePath), toolName) if _, statErr := os.Stat(candidate); statErr == nil { - return candidate, nil + return candidate, true } } - - if resolved, lookErr := exec.LookPath(DefaultDotnetToolName); lookErr == nil { - return resolved, nil + if resolved, err := exec.LookPath(toolName); err == nil { + return resolved, true } - - return "", fmt.Errorf(".NET tool [%s] not found. Set -dotnetToolPath or place %s in the same directory as yc.exe", DefaultDotnetToolName, DefaultDotnetToolName) + return "", false } // GetAppRuntime returns the detected runtime type for the given process.