Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 0 additions & 10 deletions internal/agent/m3/m3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions internal/agent/ondemand/ondemand.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion internal/capture/dotnet_gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/capture/dotnet_gc_async.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/capture/dotnet_heap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/capture/dotnet_thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions internal/capture/dotnet_tool_selector_others.go
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions internal/capture/dotnet_tool_selector_windows.go
Original file line number Diff line number Diff line change
@@ -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"
}
76 changes: 58 additions & 18 deletions internal/capture/dotnet_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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...)
Expand Down
19 changes: 14 additions & 5 deletions internal/cli/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 65 additions & 22 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
Expand Down Expand Up @@ -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.
Expand Down