From 98b2651d1074fb217061dda92e22c5d583ed9384 Mon Sep 17 00:00:00 2001 From: Christophe Nasarre Date: Thu, 30 Jul 2026 10:20:39 +0200 Subject: [PATCH 1/2] Fix "<." issue in frames --- .../Demos/Samples.Computer01/GlobalStruct.cs | 12 ++++ .../Samples.Computer01/MethodsSignature.cs | 61 ++++++++++++++++++- .../Datadog.Profiler.Native/FrameStore.cpp | 30 +++------ .../Helpers/EnvironmentHelper.cs | 2 +- .../Helpers/StackFrame.cs | 2 +- .../Signature/SignatureTest.cs | 49 ++++++++++++++- 6 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 profiler/src/Demos/Samples.Computer01/GlobalStruct.cs diff --git a/profiler/src/Demos/Samples.Computer01/GlobalStruct.cs b/profiler/src/Demos/Samples.Computer01/GlobalStruct.cs new file mode 100644 index 000000000000..354e43a20c69 --- /dev/null +++ b/profiler/src/Demos/Samples.Computer01/GlobalStruct.cs @@ -0,0 +1,12 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2022 Datadog, Inc. +// + +// This value type is deliberately declared outside of any namespace: when it is given as the type +// argument of a generic method, the |fg: part of the frame must not be prefixed by a '.' standing +// for the empty namespace. +internal struct GlobalStruct +{ + public int Member; +} diff --git a/profiler/src/Demos/Samples.Computer01/MethodsSignature.cs b/profiler/src/Demos/Samples.Computer01/MethodsSignature.cs index 2c5b088bf773..e221780bb434 100644 --- a/profiler/src/Demos/Samples.Computer01/MethodsSignature.cs +++ b/profiler/src/Demos/Samples.Computer01/MethodsSignature.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; #pragma warning disable SA1402 // File may only contain a single type #pragma warning disable SA1401 // Fields should be private @@ -22,7 +23,58 @@ public override void OnProcess() // because at least one of each type will be sampled. // Allocations beyond 100 KB would be fine except that we could not test // the .NET Framework runtime - TriggerExceptions(); + WithResult().GetAwaiter().GetResult(); + } + + // Same shape as BuggyBits ProductsController.WithResult() / GetSyncOverAsync(): the exception + // is thrown from the synchronous segment of an async method, so the compiler-generated state + // machines and AsyncMethodBuilderCore.Start are on the stack when it is + // captured. Each state machine is a type nested in MethodsSignature: its name must keep both + // the namespace and the enclosing type. + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private async Task WithResult() + { + return await GetSyncOverAsync(); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private async Task GetSyncOverAsync() + { + // nothing is awaited before this call: the whole chain below runs in the synchronous + // segment of the state machine, i.e. under AsyncMethodBuilderCore.Start<...> + RunStateMachine(); + + await Task.Yield(); + return 42; + } + + // The state machines generated by the compiler for the two methods above are structs in release + // builds but classes in debug builds. In the latter case, the shared canonical instantiation of + // Start is used, so the exact state machine type is not available to the profiler. + // Driving our own state machine, always a struct, gives the same frames in both configurations. + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private void RunStateMachine() + { + var builder = AsyncTaskMethodBuilder.Create(); + var stateMachine = new SyncOverAsyncStateMachine { Owner = this }; + + // no need to complete the builder: nothing awaits its task and the exceptions thrown by + // the chain below are caught before they could escape MoveNext() + builder.Start(ref stateMachine); + } + + private struct SyncOverAsyncStateMachine : IAsyncStateMachine + { + public MethodsSignature Owner; + + public void MoveNext() + { + Owner.TriggerExceptions(); + } + + public void SetStateMachine(IAsyncStateMachine stateMachine) + { + } } // call methods with different signatures @@ -132,6 +184,13 @@ private void ThrowGenericMethod1(T element) } else if (element is MyStruct) + { + GlobalStruct gs; + gs.Member = 42; + ThrowGenericMethod1(gs); + } + else + if (element is GlobalStruct) { ThrowGenericMethod1("this"); } diff --git a/profiler/src/ProfilerEngine/Datadog.Profiler.Native/FrameStore.cpp b/profiler/src/ProfilerEngine/Datadog.Profiler.Native/FrameStore.cpp index 3069e10e55cd..0e90a7bbb84a 100644 --- a/profiler/src/ProfilerEngine/Datadog.Profiler.Native/FrameStore.cpp +++ b/profiler/src/ProfilerEngine/Datadog.Profiler.Native/FrameStore.cpp @@ -609,12 +609,13 @@ std::tuple FrameStore::GetMethodName } else // normal namespace.type case { + // a type declared outside of any namespace has no namespace: don't prefix it with a '.' if (!ns.empty()) { - builder << ns; + builder << ns << "."; } - builder << "." << typeName; + builder << typeName; } if (i < genericParametersCount - 1) @@ -1117,30 +1118,17 @@ std::pair FrameStore::GetManagedTypeName(ICorProfilerI return std::make_pair("", "T"); } - IMetaDataImport2* pMetadata; - hr = pInfo->GetModuleMetaData(moduleId, ofRead, IID_IMetaDataImport2, reinterpret_cast(&pMetadata)); + ComPtr pMetadata; + hr = pInfo->GetModuleMetaData(moduleId, ofRead, IID_IMetaDataImport2, reinterpret_cast(pMetadata.GetAddressOf())); if (FAILED(hr)) { return std::make_pair("", "T"); } - std::string typeName = GetTypeNameFromMetadata(pMetadata, mdTypeToken); - pMetadata->Release(); - if (typeName.empty()) - { - return std::make_pair("", "T"); - } - - // look for the namespace - auto const pos = typeName.find_last_of('.'); - if (pos == std::string::npos) - { - // no namespace - return std::make_pair("", std::move(typeName)); - } - - // need to split to get the namespace and type name - return std::make_pair(typeName.substr(0, pos), typeName.substr(pos + 1)); + // the namespace and the enclosing types are not part of the metadata name of a nested type + // (such as the state machine generated for an async method): GetTypeWithNamespace() rebuilds + // them and, for a type that is not nested, splits the namespace from the type name + return GetTypeWithNamespace(pMetadata.Get(), mdTypeToken); } // use Peter Sollich way in ClrProfiler to parse the binary signature diff --git a/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/EnvironmentHelper.cs b/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/EnvironmentHelper.cs index a5086f3dc822..cde9fb5fa756 100644 --- a/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/EnvironmentHelper.cs +++ b/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/EnvironmentHelper.cs @@ -371,7 +371,7 @@ private string GetNativeLoaderPath() if (!IsRunningInCi()) { // native loader output folder - var binFolder = Path.Combine(GetSolutionDirectory(), "native-bin", "Datadog.Trace.ClrProfiler.Native", "bin"); + var binFolder = Path.Combine(GetSolutionDirectory(), "artifacts", "native-bin", "Datadog.Trace.ClrProfiler.Native"); return GetOS() switch { diff --git a/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/StackFrame.cs b/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/StackFrame.cs index e4a19beca5d6..4fc798285e2a 100644 --- a/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/StackFrame.cs +++ b/profiler/test/Datadog.Profiler.IntegrationTests/Helpers/StackFrame.cs @@ -28,7 +28,7 @@ public StackFrame(string rawStackFrame) public StackFrame(string rawStackFrame, string filename, long line) { // |lm:Datadog.Demos.ExceptionGenerator |ns:ExceptionGenerator |ct:ExceptionsProfilerTestScenario |fn:Throw1_2 - var match = Regex.Match(rawStackFrame, @"^\|lm:(?.*) \|ns:(?.*) \|ct:(?.*) \|cg:(?.*) \|fn:(?.*) \|fg:(?.*) \|sg:(?.*)$"); + var match = Regex.Match(rawStackFrame, @"^\|lm:(?.*) \|ns:(?.*) \|ct:(?.*) \|cg:(?.*) \|fn:(?.*) \|fg:(?.*) \|sg:(?.*)$"); if (!match.Success) { diff --git a/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs b/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs index a0eac153963f..965e186dfc4d 100644 --- a/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs +++ b/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs @@ -23,6 +23,17 @@ public SignatureTest(ITestOutputHelper output) [TestAppFact("Samples.Computer01", new[] { "net48", "netcoreapp3.1", "net6.0", "net8.0", })] // FIXME: .NET 9 skipping .NET 9 for now public void ValidateSignatures(string appName, string framework, string appAssembly) + { + CheckExceptionsInProfiles(framework, GetExceptionSamples(appName, framework, appAssembly)); + } + + [TestAppFact("Samples.Computer01", new[] { "net48", "netcoreapp3.1", "net6.0", "net8.0", })] // FIXME: .NET 9 skipping .NET 9 for now + public void ValidateAsyncStateMachineSignatures(string appName, string framework, string appAssembly) + { + CheckAsyncStateMachineFrames(GetExceptionSamples(appName, framework, appAssembly)); + } + + private (string Type, string Message, long Count, StackTrace Stacktrace)[] GetExceptionSamples(string appName, string framework, string appAssembly) { var runner = new TestApplicationRunner(appName, framework, appAssembly, _output, commandLine: "--scenario 20"); EnvironmentHelper.DisableDefaultProfilers(runner); @@ -33,8 +44,40 @@ public void ValidateSignatures(string appName, string framework, string appAssem runner.Run(agent); Assert.True(agent.NbCallsOnProfilingEndpoint > 0); - var exceptionSamples = SamplesHelper.ExtractExceptionSamples(runner.Environment.PprofDir).ToArray(); - CheckExceptionsInProfiles(framework, exceptionSamples); + return SamplesHelper.ExtractExceptionSamples(runner.Environment.PprofDir).ToArray(); + } + + private static void CheckAsyncStateMachineFrames((string Type, string Message, long Count, StackTrace Stacktrace)[] exceptionSamples) + { + var frames = exceptionSamples + .SelectMany(sample => Enumerable.Range(0, sample.Stacktrace.FramesCount).Select(i => sample.Stacktrace[i])) + .ToArray(); + + // A state machine is a type nested in the type declaring the async method: the generic + // parameter of Start must carry its namespace and its enclosing type, exactly + // like the signature already does. The frame is AsyncMethodBuilderCore.Start on .NET Core + // and AsyncTaskMethodBuilder.Start on .NET Framework. + var startRawAdornments = frames + .Where(frame => frame.Function == "Start") + .Select(frame => frame.FunctionAdornment) + .ToArray(); + + // split code to debug more easily outside of LINQ + var startAdornments = startRawAdornments + .Distinct() + .ToArray(); + + // MethodsSignature drives its own state machine: a struct is never shared between + // instantiations so its exact type is always known, whatever the configuration used to + // build the sample + startAdornments.Should().Contain(""); + + // the state machines generated by the compiler are structs in release builds but classes in + // debug builds: in the latter case, only the shared canonical instantiation is known () + startAdornments.Should().Contain(""); + + // regression test to check the "type with empty namespace" bug is fixed + frames.Should().OnlyContain(frame => !frame.FunctionAdornment.StartsWith("<.")); } private static void CheckExceptionsInProfiles(string framework, (string Type, string Message, long Count, StackTrace Stacktrace)[] exceptionSamples) @@ -56,6 +99,7 @@ private static void CheckExceptionsInProfiles(string framework, (string Type, st new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:GenericClassForValueTypeTest |cg: |fn:ThrowOneGenericFromType |fg: |sg:(TKey value)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod2 |fg: |sg:(T0 key1, System.Int32 value1, System.Int32 value2, T2 key2, T3 key3, System.Collections.Generic.List listOfTValue)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(T0 element)"), + new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(GlobalStruct element)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(Samples.Computer01.MyStruct element)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(T0 element)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(System.Boolean element)"), @@ -80,6 +124,7 @@ private static void CheckExceptionsInProfiles(string framework, (string Type, st new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:GenericClassForValueTypeTest |cg: |fn:ThrowOneGenericFromType |fg: |sg:(TVal value)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod2 |fg: |sg:(T0 key1, System.Int32 value1, System.Int32 value2, T2 key2, T3 key3, System.Collections.Generic.List listOfTValue)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(T0 element)"), + new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(GlobalStruct element)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(Samples.Computer01.MyStruct element)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(T0 element)"), new StackFrame("|lm:Samples.Computer01 |ns:Samples.Computer01 |ct:MethodsSignature |cg: |fn:ThrowGenericMethod1 |fg: |sg:(System.Boolean element)"), From ae27004cb82967c0d71f81f09634de9fb8bbc7fe Mon Sep 17 00:00:00 2001 From: Christophe Nasarre Date: Fri, 31 Jul 2026 14:01:33 +0200 Subject: [PATCH 2/2] Fix issues raised by AI --- .../Demos/Samples.Computer01/GlobalStruct.cs | 5 ++++ .../Signature/SignatureTest.cs | 23 ++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/profiler/src/Demos/Samples.Computer01/GlobalStruct.cs b/profiler/src/Demos/Samples.Computer01/GlobalStruct.cs index 354e43a20c69..62dcb6c1ba7f 100644 --- a/profiler/src/Demos/Samples.Computer01/GlobalStruct.cs +++ b/profiler/src/Demos/Samples.Computer01/GlobalStruct.cs @@ -3,6 +3,9 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2022 Datadog, Inc. // +#pragma warning disable SA1401 // Fields should be private + + // This value type is deliberately declared outside of any namespace: when it is given as the type // argument of a generic method, the |fg: part of the frame must not be prefixed by a '.' standing // for the empty namespace. @@ -10,3 +13,5 @@ internal struct GlobalStruct { public int Member; } + +#pragma warning restore SA1401 // Fields should be private diff --git a/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs b/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs index 965e186dfc4d..d49a9635c34f 100644 --- a/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs +++ b/profiler/test/Datadog.Profiler.IntegrationTests/Signature/SignatureTest.cs @@ -49,24 +49,27 @@ public void ValidateAsyncStateMachineSignatures(string appName, string framework private static void CheckAsyncStateMachineFrames((string Type, string Message, long Count, StackTrace Stacktrace)[] exceptionSamples) { - var frames = exceptionSamples - .SelectMany(sample => Enumerable.Range(0, sample.Stacktrace.FramesCount).Select(i => sample.Stacktrace[i])) - .ToArray(); - // A state machine is a type nested in the type declaring the async method: the generic // parameter of Start must carry its namespace and its enclosing type, exactly // like the signature already does. The frame is AsyncMethodBuilderCore.Start on .NET Core // and AsyncTaskMethodBuilder.Start on .NET Framework. - var startRawAdornments = frames - .Where(frame => frame.Function == "Start") - .Select(frame => frame.FunctionAdornment) + var frames = exceptionSamples + .SelectMany(sample => Enumerable.Range(0, sample.Stacktrace.FramesCount).Select(i => sample.Stacktrace[i])) + .Distinct() .ToArray(); // split code to debug more easily outside of LINQ - var startAdornments = startRawAdornments + var startFrames = frames + .Where(frame => frame.Function == "Start") + .Distinct() + .ToArray(); + + var startAdornments = startFrames + .Select(frame => frame.FunctionAdornment) .Distinct() .ToArray(); +#if DEBUG // MethodsSignature drives its own state machine: a struct is never shared between // instantiations so its exact type is always known, whatever the configuration used to // build the sample @@ -75,6 +78,10 @@ private static void CheckAsyncStateMachineFrames((string Type, string Message, l // the state machines generated by the compiler are structs in release builds but classes in // debug builds: in the latter case, only the shared canonical instantiation is known () startAdornments.Should().Contain(""); +#else + startAdornments.Should().Contain("d__2>"); + startAdornments.Should().Contain("d__1>"); +#endif // regression test to check the "type with empty namespace" bug is fixed frames.Should().OnlyContain(frame => !frame.FunctionAdornment.StartsWith("<."));