Skip to content

Convert funceval to UCO pattern#126809

Open
am11 wants to merge 22 commits intodotnet:mainfrom
am11:feature/MDCS-to-UCOA-pattern2
Open

Convert funceval to UCO pattern#126809
am11 wants to merge 22 commits intodotnet:mainfrom
am11:feature/MDCS-to-UCOA-pattern2

Conversation

@am11
Copy link
Copy Markdown
Member

@am11 am11 commented Apr 12, 2026

Built on top of #126542.

Last part of #123864 before the final cleanups.

@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Apr 12, 2026
@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 12, 2026

cc @jkotas, @janvorli

Tested it with this program in VSCode:

Program.cs

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;

// Test various funceval scenarios that exercise DoNormalFuncEval
var dt = new DateTime(2026, 4, 12);
var list = new System.Collections.Generic.List<int> { 1, 2, 3 };
var str = "hello funceval";
int x = 42;
double pi = 3.14;
bool flag = true;
var dict = new System.Collections.Generic.Dictionary<string, int> { ["one"] = 1, ["two"] = 2 };
var arr = new int[] { 10, 20, 30 };
object boxed = 99;
var guid = Guid.NewGuid();
var span = "hello".AsSpan(); // not directly evaluable but interesting
var tuple = (Name: "Alice", Age: 30);

// Breakpoint here - then evaluate expressions in Watch/Immediate window:
//
// === Instance methods on value types (boxes 'this') ===
//   dt.ToString()              → value type instance method
//   dt.AddDays(1)              → value type method with arg, returns value type
//   guid.ToString()            → Guid.ToString()
//   pi.ToString("F1")          → double method with string arg
//   tuple.ToString()           → ValueTuple instance method
//
// === Instance methods on ref types ===
//   str.Length                  → string property (handle-based 'this')
//   str.Contains("func")       → string method with string arg
//   str.Substring(6, 4)        → string method with two int args
//   list.Count                 → generic ref type property
//   list.Contains(2)           → generic method with boxed int arg
//   list[0]                    → indexer (get_Item)
//   dict["one"]                → dictionary indexer
//   dict.ContainsKey("two")    → dictionary method
//   arr.Length                 → array property
//
// === Primitive 'this' (box + call) ===
//   x.ToString()               → int.ToString()
//   x.GetType()                → int.GetType() returns System.Int32
//   flag.ToString()            → bool.ToString()
//   x.CompareTo(10)            → int.CompareTo(int)
//
// === Static methods (this = null) ===
//   string.Concat("a", "b")   → static with ref type args
//   string.IsNullOrEmpty("")   → static with ref type arg
//   int.Parse("123")           → static returning primitive
//   Math.Max(3, 7)             → static with two primitive args
//   DateTime.UtcNow            → static property returning value type
//   Environment.ProcessId      → static property returning int
//   RuntimeInformation.FrameworkDescription → static property returning string
//   Guid.NewGuid()             → static method returning value type
//   Convert.ToInt32("42")      → static with string arg returning int
//
// === NEW_OBJECT (constructor) ===
//   new DateTime(2000, 1, 1)   → value type ctor with primitive args
//   new List<int>()            → ref type default ctor
//   new string('x', 5)        → string ctor with char + int
//   new object()               → simplest ctor
//
// === Boxed object ===
//   boxed.ToString()           → call on boxed int (ELEMENT_TYPE_OBJECT)
//   boxed.GetType()            → GetType on boxed int
//
Debugger.Break();

Console.WriteLine($"dt={dt}, list.Count={list.Count}, str={str}, x={x}");
Console.WriteLine($"dict={dict.Count}, arr={arr.Length}, guid={guid}, pi={pi}, flag={flag}");

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "funceval-test (branch)",
            "type": "coreclr",
            "request": "launch",
            "program": "c:\\temp\\runtime\\artifacts\\bin\\testhost\\net11.0-windows-Release-x64\\dotnet.exe",
            "args": ["exec", "C:\\temp\\funceval-test\\bin\\Debug\\net11.0\\funceval-test.dll"],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            "env": {
                "DOTNET_ROOT": "c:\\temp\\runtime\\artifacts\\bin\\testhost\\net11.0-windows-Release-x64"
            }
        },
        {
            "name": "funceval-test (main)",
            "type": "coreclr",
            "request": "launch",
            "program": "c:\\temp\\runtime\\runtime2\\artifacts\\bin\\testhost\\net11.0-windows-Release-x64\\dotnet.exe",
            "args": ["exec", "C:\\temp\\funceval-test\\bin\\Debug\\net11.0\\funceval-test.dll"],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            "env": {
                "DOTNET_ROOT": "c:\\temp\\runtime\\runtime2\\artifacts\\bin\\testhost\\net11.0-windows-Release-x64"
            }
        }
    ]
}

Results match main branch:

PR Main

@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

@jkotas
Copy link
Copy Markdown
Member

jkotas commented Apr 12, 2026

Built on top of #126542.

Does it depend on any of the changes in #126542 given that it just calls the public MethodInfo.Invoke API?

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 12, 2026

Built on top of #126542.

Does it depend on any of the changes in #126542 given that it just calls the public MethodInfo.Invoke API?

#126542 moved InvokeMethod to managed and this is tested end to end that way. I can cherry-pick ffa8a51 on main if we want this one to go in first.

@jkotas
Copy link
Copy Markdown
Member

jkotas commented Apr 12, 2026

I am not sure which one will go first. If these changes are independent, it would make more sense to submit them as independent PRs.

Copy link
Copy Markdown
Member

@jkotas jkotas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the Microsoft maintainers will have to run internal VS debugger tests on this before it gets merged.

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 14, 2026

Failures are known, the new one is unrelated deadlettered leg. Results still match before/after #126809 (comment)

@janvorli
Copy link
Copy Markdown
Member

Let me run the private diagnostic tests with this change.

@janvorli
Copy link
Copy Markdown
Member

There is about 15 failed tests with this change (compared to the commit right before your change). Moreover, I've found that we also have some additional failures in these tests that one of your previous changes - #126222 has introduced. Since I have run those tests at one point during your PR, it seems that some changes made after that has introduced the problem.
Let me get my head around these failures and then get back to you.

@janvorli
Copy link
Copy Markdown
Member

So, regarding the issues introduced by the old PR, the DacDbiInterfaceImpl::UnwindStackWalkFrame needs to be updated to skip frames of "System.Environment.CallEntryPoint". I've tried a quick hack (just comparing method name string) and found that fixes two of the three EH related failures.
We probably also want to skip it in the ProfilerStackWalkCallback, DebuggerStepper::IsInterestingFrame and maybe handle it in the DacDbiInterfaceImpl::GetStackWalkCurrentFrameInfo too (create a new enum entry in FrameType, return it from there and handle it in CordbStackWalk::GetFrameWorker). See my old PR that ported the NativeAOT EH, you'll see where I've added this handling.

The remaining issue from the old PR is strange. The debugger can see a stack where the System.Environment.CallEntryPoint is at the top of the stack, with Main at the bottom. That doesn't make sense, I am looking at the test to see if I can isolate a standalone repro.

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 14, 2026

@janvorli, thank you for the investigations. The DAC accessibility should be fixed by e2d4338. You can also push additional changes to this branch. Hopefully it will save us rounds of PRs. :)

@janvorli
Copy link
Copy Markdown
Member

That doesn't make sense,

I've found it actually does make sense, the frame on top of the stack is the filter funclet from the System.Environment.CallEntryPoint. What the test does is that when it breaks on an unhandled exception, it then performs a "step" command, which makes the debugger step into the filter funclet.

It sounds like something that the diagnostics folks will need to think about, as I don't think we want the debugger to step into this internal filter when the user "steps" after the debugger breaks on an an unhandled exception.

@jkotas
Copy link
Copy Markdown
Member

jkotas commented Apr 14, 2026

thank you for the investigations. The DAC accessibility should be fixed by e2d4338. You can also push additional changes to this branch. Hopefully it will save us rounds of PRs. :)

Can we create separate PR with fixes to these regressions? I would like to get to a clean state first before taking more risky changes.

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 14, 2026

Can we create separate PR with fixes to these regressions? I would like to get to a clean state first before taking more risky changes.

I was thinking to push the commits here for now then split PR once private diagnostics test pass. Cherry-picking these is simple as they are touching different files. It's to reduce the hassle for @janvorli to switch between branches.

@janvorli
Copy link
Copy Markdown
Member

@am11 now regarding the failures introduced by the funceval changes. I can see a pattern of which the following is the simplest example. Running funceval in the testing debugger on a function that returns bool gets:
Without your change:

result = False

With your change:

result = System.Boolean <System.Object>
        m_value=False

Stepping through the debugger itself, the difference is that with your change, the result type is CorElementType.ELEMENT_TYPE_CLASS. I have problems attaching VS to the testing debugger with the state before your change for some reason, but I think before it was CorElementType.ELEMENT_TYPE_BOOLEAN.

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 14, 2026

@janvorli, it's interesting that vscode was showing me unboxed bools. I have just tested with VS 2022 and it is also showing me unboxed value:
image
Could you please try again and let me know if the problem is fixed? Otherwise, repro steps would be nice. :)

@janvorli
Copy link
Copy Markdown
Member

@am11 the bool issue is gone with your changes, but similar issues persists.
Unfortunately this is related to how the testing debugger does stuff and so I have no way to get you a repro.

With generics when the testing debugger prints a local variable:
Expected:

gt=GenericType<System.String,System.Int32> <GenericType[System.String,System.Int32]>

Actual:

gt=GenericType<System.String,System.Int32> <System.Object>

And I can actually see the same issue for plain structs.

But I believe these were being boxed even before your change, so the issue must be something else than boxing. It is hard to debug it on the testing debugger side as many things referring to the debuggee are COM objects and VS shows nothing interesting about those. I'll try to enable logging for LF_CORDB to see if it uncovers something interesting and possibly add extra logging. Or debug it on Linux where I can attach lldb to the debuggee even if it is being debugged by the testing debugger. That isn't possible on Windows (except for nonivasive debugging where you cannot step through code)

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 14, 2026

Seems like generic types are printing as expected:
image

@janvorli, I wonder if it's possible for us to add some tests for ICorDebug interface under src/tests? I'd imagine it shouldn't be impossible as it doesn't even need an actual debugger but rather mocked layer initializing and sending commands with code for funceval.

@janvorli
Copy link
Copy Markdown
Member

@am11 I have found the issue is that your change treats special types like strings the same way as value types. I was misled into thinking the problem was boxing of the structs etc, but it turned out that the problematic cases where when the debugger used funceval to call ToString on a struct. Added logging shows that the funceval produced string and in the state before your change, it was setting boxing to Debugger::OnlyPrimitivesUnboxed.
Looking at the previously used UnpackFuncEvalResult, it went through the else branch here for the string:

else
{
//
// Other FuncEvals return primitives as unboxed.
//
pDE->m_retValueBoxing = Debugger::OnlyPrimitivesUnboxed;
}

Then in this "if" below, it has created the strong handle like it is done for value types. The IsElementTypeSpecial(retClassET) part of the condition make it go into the "if" body
if ((pDE->m_retValueBoxing == Debugger::AllBoxed) ||
!RetValueType.IsNull() ||
IsElementTypeSpecial(retClassET))
{
LOG((LF_CORDB, LL_EVERYTHING, "Creating strong handle for boxed DoNormalFuncEval result.\n"));
OBJECTHANDLE oh = AppDomain::GetCurrentDomain()->CreateStrongHandle(ArgSlotToObj(pDE->m_result[0]));
pDE->m_result[0] = (INT64)(LONG_PTR)oh;
pDE->m_vmObjectHandle = VMPTR_OBJECTHANDLE::MakePtr(oh);
}

I've tried to make your new code work that way, but apparently there is something else missing, as the debugger has printed "Error: Handle has been disposed". I have no idea what handle was being referred to yet.

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 15, 2026

@janvorli, thanks. I think the old approach (with MethodDescCallSite machinery) was treating IsElementTypeSpecial differently and only creating strong handle for AllBoxed. I have split that in ee224c6.

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 15, 2026

Can we create separate PR with fixes to these regressions? I would like to get to a clean state first before taking more risky changes.

I was thinking to push the commits here for now then split PR once private diagnostics test pass. Cherry-picking these is simple as they are touching different files. It's to reduce the hassle for @janvorli to switch between branches.

Moved those changes to #126927 and reverted here.

//
// Do Step 1e - Gather info from runtime about args (may trigger a GC).
//
// GC-protect all arg addresses as interior pointers before any GC-triggering
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are calling AllocateObject above that can trigger GC. How are the pointers inside GetArgData protected during that AllocateObject?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pointers inside GetArgData are now GC-protected as interior pointers before AllocateObject (and all other GC-triggering calls). I've moved the pArgAddrs setup + GCPROTECT_BEGININTERIOR_ARRAY to the very top of DoNormalFuncEval, before ResolveFuncEvalGenericArgInfo and AllocateObject.

@janvorli
Copy link
Copy Markdown
Member

@am11 even after your last change it doesn't work. I can see that the DoNormalFunceval took the expected path, the pDE->m_resultType was System.String, the ucoGc.resultObj was of type System.String.

So I've spent more time debugging it and it turns out the problem is actually at the funceval arguments processing. The thing is that the debugger calls ToString on the struct via funceval. But we end up boxing the "this" argument. The original code didn't do that - dumping the type of *pObjectRefArray after the call to BoxFuncEvalThisParameter is the struct in the old state case, but the type ucoGc.thisArg in your case is System.Object.

@janvorli
Copy link
Copy Markdown
Member

It seems the original code would only box "this" argument if argData[0].argElementType == ELEMENT_TYPE_VALUETYPE and when not pDE->m_md->GetMethodTable()->IsValueType()

@am11
Copy link
Copy Markdown
Member Author

am11 commented Apr 15, 2026

@janvorli, based on your description, I have pushed a change. When pFEArgInfo is NULL (the 'this' arg), it reads the concrete type from fullArgType instead of using the declaring type of the method.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-Diagnostics-coreclr community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants