From db2781a30f5b3a125ea6a0a0a0964199b9f1bdb4 Mon Sep 17 00:00:00 2001 From: mahesh Date: Thu, 7 May 2026 09:08:55 +0530 Subject: [PATCH 1/2] For .NET capture, the yc-dot-net.exe file was build on 64-bit machine. Due to that if the user is running target process on 32-bit machine then the thread dump and heap data capture was failing. To fix this now generating 2 versions of yc-dot-net.exe file for 32-bit and 64-bit. Enhanced the yc-360 script to automatically read this file based on the OS version. --- .../capture/dotnet_tool_selector_others.go | 7 ++ .../capture/dotnet_tool_selector_windows.go | 85 +++++++++++++++++++ internal/capture/dotnet_utils.go | 81 +++++++++++++++++- 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 internal/capture/dotnet_tool_selector_others.go create mode 100644 internal/capture/dotnet_tool_selector_windows.go diff --git a/internal/capture/dotnet_tool_selector_others.go b/internal/capture/dotnet_tool_selector_others.go new file mode 100644 index 0000000..1671cfc --- /dev/null +++ b/internal/capture/dotnet_tool_selector_others.go @@ -0,0 +1,7 @@ +//go:build !windows + +package capture + +func resolveDotnetToolByPid(pid int) (string, bool, error) { + return "", false, 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..3a3fbfe --- /dev/null +++ b/internal/capture/dotnet_tool_selector_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package capture + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "golang.org/x/sys/windows" + "yc-agent/internal/config" +) + +const ( + dotnetToolX86 = "yc-dot-net-x86.exe" + dotnetToolX64 = "yc-dot-net-x64.exe" +) + +// resolveDotnetToolByPid picks helper binary by target process architecture. +// Returns (toolPath, true, nil) when selection succeeded. +// Returns ("", false, nil) when no architecture-specific binary was found. +func resolveDotnetToolByPid(pid int) (string, bool, error) { + targetArch, err := detectWindowsProcessArch(pid) + if err != nil { + return "", false, nil // non-fatal: caller will use default resolver + } + + preferred := dotnetToolX64 + if targetArch == "x86" { + preferred = dotnetToolX86 + } + + // Search order: + // 1) sibling to yc executable + // 2) PATH + // 3) fallback default name (yc-dot-net.exe) + if p, ok := findToolNearYcOrPath(preferred); ok { + return p, true, nil + } + if p, ok := findToolNearYcOrPath(config.DefaultDotnetToolName); ok { + return p, true, nil + } + + return "", false, fmt.Errorf(".NET helper for target PID %d (%s) not found. expected %s (or %s)", pid, targetArch, preferred, config.DefaultDotnetToolName) +} + +func findToolNearYcOrPath(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, true + } + } + if resolved, err := exec.LookPath(toolName); err == nil { + return resolved, true + } + return "", false +} + +func detectWindowsProcessArch(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..7910b25 100644 --- a/internal/capture/dotnet_utils.go +++ b/internal/capture/dotnet_utils.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "yc-agent/internal/capture/executils" "yc-agent/internal/config" @@ -53,10 +54,86 @@ func ensureDotnetToolResolved() (string, error) { return resolved, nil } +func tryParsePid(value string) (int, bool) { + if value == "" { + return 0, false + } + pid, err := strconv.Atoi(value) + if err != nil || pid <= 0 { + return 0, false + } + return pid, true +} + +// extractTargetPidFromArgs supports flexible argument ordering, including: +// - "-p 1234" / "--pid 1234" +// - "-p=1234" / "--pid=1234" +// - positional numeric argument (legacy/fallback) +func extractTargetPidFromArgs(args []string) (int, bool) { + // Prefer explicit pid flags first. + for i := 0; i < len(args); i++ { + arg := strings.TrimSpace(args[i]) + switch { + case arg == "-p" || arg == "--pid": + if i+1 < len(args) { + if pid, ok := tryParsePid(strings.TrimSpace(args[i+1])); ok { + return pid, true + } + } + case strings.HasPrefix(arg, "-p="): + if pid, ok := tryParsePid(strings.TrimPrefix(arg, "-p=")); ok { + return pid, true + } + case strings.HasPrefix(arg, "--pid="): + if pid, ok := tryParsePid(strings.TrimPrefix(arg, "--pid=")); ok { + return pid, true + } + } + } + + // Fallback: first positive integer token in args. + for _, arg := range args { + if pid, ok := tryParsePid(strings.TrimSpace(arg)); ok { + return pid, true + } + } + return 0, false +} + +// resolveDotnetToolForArgs picks the best .NET helper binary for the command: +// - If user configured DotnetToolPath explicitly, keep using it. +// - Else, if a target PID can be extracted from args, prefer architecture-specific +// helper (yc-dot-net-x86.exe / yc-dot-net-x64.exe) and fall back to default resolver. +func resolveDotnetToolForArgs(args []string) (string, error) { + if path := config.GlobalConfig.DotnetToolPath; path != "" { + return path, nil + } + + // Reuse the canonical pid parsed by config flags first (-p can appear anywhere). + if pid, ok := tryParsePid(strings.TrimSpace(config.GlobalConfig.Pid)); ok { + if tool, found, err := resolveDotnetToolByPid(pid); err != nil { + return "", err + } else if found { + return tool, nil + } + } + + // Fallback for direct/internal invocations that bypass GlobalConfig parsing. + if pid, ok := extractTargetPidFromArgs(args); ok { + if tool, found, err := resolveDotnetToolByPid(pid); err != nil { + return "", err + } else if found { + return tool, nil + } + } + + return ensureDotnetToolResolved() +} + // executeDotnetTool runs the configured .NET helper 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() + toolPath, err := resolveDotnetToolForArgs(args) if err != nil { return nil, err } @@ -147,7 +224,7 @@ func executeDotnetTool(args []string, outputPath string) (*os.File, error) { // startDotnetToolInBackground starts the configured .NET helper 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() + toolPath, err := resolveDotnetToolForArgs(args) if err != nil { return nil, err } From 06986157efc2fad0def85cf8d71d98f9c4d1a579 Mon Sep 17 00:00:00 2001 From: Andy Librian Date: Sun, 10 May 2026 16:44:13 +0700 Subject: [PATCH 2/2] Resolve .NET tool per-PID with cpu architecture detection Detect the target process architecture at capture time and pick the matching yc-dot-net-x86.exe or yc-dot-net-x64.exe tool path, instead of eagerly resolving a single tool path during validation. --- go.mod | 2 +- internal/agent/m3/m3.go | 10 -- internal/agent/ondemand/ondemand.go | 7 - internal/capture/dotnet_gc.go | 2 +- internal/capture/dotnet_gc_async.go | 2 +- internal/capture/dotnet_heap.go | 2 +- internal/capture/dotnet_thread.go | 2 +- .../capture/dotnet_tool_selector_others.go | 7 +- .../capture/dotnet_tool_selector_windows.go | 52 +------ internal/capture/dotnet_utils.go | 135 +++++++----------- internal/cli/validation.go | 19 ++- internal/config/config.go | 87 ++++++++--- 12 files changed, 139 insertions(+), 188 deletions(-) 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 index 1671cfc..122138a 100644 --- a/internal/capture/dotnet_tool_selector_others.go +++ b/internal/capture/dotnet_tool_selector_others.go @@ -2,6 +2,9 @@ package capture -func resolveDotnetToolByPid(pid int) (string, bool, error) { - return "", false, nil +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 index 3a3fbfe..c69a8ec 100644 --- a/internal/capture/dotnet_tool_selector_windows.go +++ b/internal/capture/dotnet_tool_selector_windows.go @@ -3,62 +3,12 @@ package capture import ( - "fmt" "os" - "os/exec" - "path/filepath" "golang.org/x/sys/windows" - "yc-agent/internal/config" ) -const ( - dotnetToolX86 = "yc-dot-net-x86.exe" - dotnetToolX64 = "yc-dot-net-x64.exe" -) - -// resolveDotnetToolByPid picks helper binary by target process architecture. -// Returns (toolPath, true, nil) when selection succeeded. -// Returns ("", false, nil) when no architecture-specific binary was found. -func resolveDotnetToolByPid(pid int) (string, bool, error) { - targetArch, err := detectWindowsProcessArch(pid) - if err != nil { - return "", false, nil // non-fatal: caller will use default resolver - } - - preferred := dotnetToolX64 - if targetArch == "x86" { - preferred = dotnetToolX86 - } - - // Search order: - // 1) sibling to yc executable - // 2) PATH - // 3) fallback default name (yc-dot-net.exe) - if p, ok := findToolNearYcOrPath(preferred); ok { - return p, true, nil - } - if p, ok := findToolNearYcOrPath(config.DefaultDotnetToolName); ok { - return p, true, nil - } - - return "", false, fmt.Errorf(".NET helper for target PID %d (%s) not found. expected %s (or %s)", pid, targetArch, preferred, config.DefaultDotnetToolName) -} - -func findToolNearYcOrPath(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, true - } - } - if resolved, err := exec.LookPath(toolName); err == nil { - return resolved, true - } - return "", false -} - -func detectWindowsProcessArch(pid int) (string, error) { +func detectTargetArch(pid int) (string, error) { h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { return "", err diff --git a/internal/capture/dotnet_utils.go b/internal/capture/dotnet_utils.go index 7910b25..3b7e457 100644 --- a/internal/capture/dotnet_utils.go +++ b/internal/capture/dotnet_utils.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" "yc-agent/internal/capture/executils" "yc-agent/internal/config" @@ -27,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. @@ -40,106 +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 - } - resolved, err := config.ResolveDotnetToolPath() - if err != nil { - return "", err +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 } - config.GlobalConfig.DotnetToolPath = resolved - return resolved, nil -} -func tryParsePid(value string) (int, bool) { - if value == "" { - return 0, false + // arch matched + arch, detectErr := detectTargetArch(pid) + if detectErr != nil { + logger.Warn().Err(detectErr).Int("pid", pid).Msg("could not detect target arch") } - pid, err := strconv.Atoi(value) - if err != nil || pid <= 0 { - return 0, false - } - return pid, true -} - -// extractTargetPidFromArgs supports flexible argument ordering, including: -// - "-p 1234" / "--pid 1234" -// - "-p=1234" / "--pid=1234" -// - positional numeric argument (legacy/fallback) -func extractTargetPidFromArgs(args []string) (int, bool) { - // Prefer explicit pid flags first. - for i := 0; i < len(args); i++ { - arg := strings.TrimSpace(args[i]) - switch { - case arg == "-p" || arg == "--pid": - if i+1 < len(args) { - if pid, ok := tryParsePid(strings.TrimSpace(args[i+1])); ok { - return pid, true - } - } - case strings.HasPrefix(arg, "-p="): - if pid, ok := tryParsePid(strings.TrimPrefix(arg, "-p=")); ok { - return pid, true - } - case strings.HasPrefix(arg, "--pid="): - if pid, ok := tryParsePid(strings.TrimPrefix(arg, "--pid=")); ok { - return pid, true - } + if arch != "" { + name := config.DotnetToolNameForArch(arch) + if p, ok := config.FindDotnetToolNearYcOrPath(name); ok { + return DotnetToolResolution{Path: p, Source: DotnetSourceArchMatched}, nil } - } - // Fallback: first positive integer token in args. - for _, arg := range args { - if pid, ok := tryParsePid(strings.TrimSpace(arg)); ok { - return pid, true - } + return DotnetToolResolution{}, fmt.Errorf(".NET tool for PID %d (%s) not found. expected %s next to yc or on PATH", pid, arch, name) } - return 0, false -} -// resolveDotnetToolForArgs picks the best .NET helper binary for the command: -// - If user configured DotnetToolPath explicitly, keep using it. -// - Else, if a target PID can be extracted from args, prefer architecture-specific -// helper (yc-dot-net-x86.exe / yc-dot-net-x64.exe) and fall back to default resolver. -func resolveDotnetToolForArgs(args []string) (string, error) { - if path := config.GlobalConfig.DotnetToolPath; path != "" { - return path, nil - } - - // Reuse the canonical pid parsed by config flags first (-p can appear anywhere). - if pid, ok := tryParsePid(strings.TrimSpace(config.GlobalConfig.Pid)); ok { - if tool, found, err := resolveDotnetToolByPid(pid); err != nil { - return "", err - } else if found { - return tool, nil + // 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 } - // Fallback for direct/internal invocations that bypass GlobalConfig parsing. - if pid, ok := extractTargetPidFromArgs(args); ok { - if tool, found, err := resolveDotnetToolByPid(pid); err != nil { - return "", err - } else if found { - return tool, 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) } - - return ensureDotnetToolResolved() + 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 := resolveDotnetToolForArgs(args) +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) @@ -221,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 := resolveDotnetToolForArgs(args) +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.