8

I've written a Visual Studio debugging visualizer which targets DateTime (repo). My issue is that the debugger side only passes the target value to the debuggee side if the target expression is of object, not of DateTime (issue).

I've published a GH repo containing an MCVE that reproduces the problem. The debugger side looks like this:

protected override void Show(IDialogVisualizerService windowService, IVisualizerObjectProvider objectProvider) {
    var response = objectProvider.TransferObject(5);

    var msg = response switch {
        string s => s,
        IEnumerable e => string.Join(", ", e.Cast<object>()),
        _ => "Unhandled type"
    };

    MessageBox.Show(msg);
}

and the debuggee side looks like this:

public override void TransferData(object target, Stream incomingData, Stream outgoingData) {
    int? repetitions = Deserialize(incomingData) switch {
        int i when i > 0 => i,
        string s when int.TryParse(s, out int i) && i > 0 => i,
        _ => null
    };

    object toSerialize =
        repetitions is null ? $"Invalid value for repetitions" :
        target switch {
            DateTime dt => Repeat(dt, repetitions.Value).ToArray(),
            null => $"{nameof(target)} is null",
            _ => $"Not implemented for target of type {target.GetType().FullName}" as object
        };

    Serialize(outgoingData, toSerialize);
}

After I build and install the visualizer, and begin debugging the following code:

var dte = DateTime.UtcNow;
object o = dte;

if I hover over o and trigger the visualizer, the target DateTime gets passed to the debuggee side, and returns an array of DateTime. But if I trigger the visualizer on dte, I get back the string target is null, implying the debugge side has received null in the target parameter.

What could be causing this? How could I resolve it?


Some random notes

  • It's not because the debugger side is always 32-bit and the debuggee side is sometimes 64-bit.
  • Nor is it because of different TFMs - when the debugger side targets .NET Framework, while the debuggee side can target .NET Standard or .NET Core.
  • Only the TransferData override is affected; the GetData override always gets the target value (I'm actually using GetData to workaround this, but I'd really rather use GetData for something else.) I've attempted to test ReplaceData / ReplaceObject, but the IsObjectReplaceable property always returns false.
  • I've tested against other value types -- TimeSpan, DateTimeOffset, and a custom struct -- and seen the same behavior. When I tested against int, however, the target process crashes and the debug session is interrupted.
  • Targeting against a DateTime? shows the same behavior as DateTime; I imagine this is because they're both serialized the same way.

Stack trace of the exception hit when similarly targeting an int

In response to this comment, the error message when visualizing an int is as follows:

The target process exited with code -1073740791 (0xC0000409) while evaluating the function 'Microsoft.VisualStudio.DebuggerVisualizers.DebuggeeSide.Impl.ClrCustomVisualizerDebuggeeHost.TransferData'.

If the problem happens regularly, consider disabling the Tools->Options setting "Debugging->General->Enable property evaluation and other implicit function calls" or debugging the cause by evaluating the expression from the Immediate window. See help for information on doing this.

followed by another message:

Could not load the custom viewer.

upon which the target process crashes and the debugging session ends.

I tried unsuccessfully to attach a debugger using a code breakpoint (Debugger.Break()). If I return the call stack from the visualizer (new System.Diagnostics.StackTrace().ToString()) and the visualizer runs successfully, I get the following:

at SimpleValueTypeVisualizer.Debuggee.VisualizerObjectSource.TransferData(Object target, Stream incomingData, Stream outgoingData)

at Microsoft.VisualStudio.DebuggerVisualizers.DebuggeeSide.Impl.ClrCustomVisualizerDebuggeeHost.TransferData(Object visualizedObject, Byte[] uiSideData)

at TestNoRef.Program.Main(String[] args)

which would seem to imply some exception at Microsoft.VisualStudio.DebuggerVisualizers.DebuggeeSide.Impl.ClrCustomVisualizerDebuggeeHost.TransferData.

When I opened the DebuggerVisualizers.dll using ILSpy, the relevant TransferData method looks like this:

// Microsoft.VisualStudio.DebuggerVisualizers.DebuggeeSide.Impl.ClrCustomVisualizerDebuggeeHost
using System.IO;

public byte[] TransferData(object visualizedObject, byte[] uiSideData)
{
    MemoryStream memoryStream = new MemoryStream();
    MemoryStream incomingData = ((uiSideData != null) ? new MemoryStream(uiSideData) : null);
    m_debuggeeSideVisualizerObject.TransferData(visualizedObject, incomingData, memoryStream);
    return memoryStream.ToArray();
}

I would guess the exception is at the third line of the method (MemoryStream incomingData = ...). But I am still unclear as to the details of the exception, particularly why the issue only arises with an unboxed value, and not with a boxed value.


Event log details

Per this comment, I am including data from the event log created when opening the visualizer on an expression of type int:

Log Name:      Application
Source:        Application Error
Date:          22/04/2021 12:14:36
Event ID:      1000
Task Category: (100)
Level:         Error
Keywords:      Classic
User:          N/A
Computer:      LAPTOP-7O43T4OO
Description:
Faulting application name: TestNoRef.exe, version: 1.0.0.0, time stamp: 0xd9f9e12d
Faulting module name: clr.dll, version: 4.8.4341.0, time stamp: 0x6023024f
Exception code: 0xc0000409
Fault offset: 0x00574845
Faulting process ID: 0x94c4
Faulting application start time: 0x01d73757c33e87c0
Faulting application path: ***********
Faulting module path: C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll
Report ID: 1dcf070b-71ff-4279-be71-822698cc6168
Faulting package full name: 
Faulting package-relative application ID: 
Event Xml:
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
  <System>
    <Provider Name="Application Error" />
    <EventID Qualifiers="0">1000</EventID>
    <Version>0</Version>
    <Level>2</Level>
    <Task>100</Task>
    <Opcode>0</Opcode>
    <Keywords>0x80000000000000</Keywords>
    <TimeCreated SystemTime="2021-04-22T09:14:36.4507272Z" />
    <EventRecordID>1180760705</EventRecordID>
    <Correlation />
    <Execution ProcessID="0" ThreadID="0" />
    <Channel>Application</Channel>
    <Computer>LAPTOP-7O43T4OO</Computer>
    <Security />
  </System>
  <EventData>
    <Data>TestNoRef.exe</Data>
    <Data>1.0.0.0</Data>
    <Data>d9f9e12d</Data>
    <Data>clr.dll</Data>
    <Data>4.8.4341.0</Data>
    <Data>6023024f</Data>
    <Data>c0000409</Data>
    <Data>00574845</Data>
    <Data>94c4</Data>
    <Data>01d73757c33e87c0</Data>
    <Data>***********</Data>
    <Data>C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll</Data>
    <Data>1dcf070b-71ff-4279-be71-822698cc6168</Data>
    <Data>
    </Data>
    <Data>
    </Data>
  </EventData>
</Event>
Zev Spitz
  • 13,950
  • 6
  • 64
  • 136
  • I took a look at your MCVE gif - is the error appearing at its end ("the target process exited...") relevant? if it is, does it produce any stack trace (on Event Viewer maybe)? – OfirD Apr 21 '21 at 10:11
  • @OfirD I've updated the question with as much information about the stack trace as I can gather. Where should I be looking in Event Viewer? – Zev Spitz Apr 21 '21 at 16:09
  • under Windows Logs > Application. then filter for errors. There would probably be anything useful in addition to what you already have, but worth looking. Also, if you could find some time to retarget the .csproj files to above .Net Framework 4.5, I think you could get more help (there's much more chance that people here on SO could target >=4.5 than =3.5). – OfirD Apr 22 '21 at 09:09
  • @OfirD Updated. RE retargeting, I'm not quite sure which project you're referring to. The Debugger project (currently targeting .NET Framework 4.8) must target .NET Framework because it's loaded into VS, although it could target almost any version of .NET Framework. The TestNoRef project could target anything, and is currently at .NET Framework 4.8. The Debuggee project is currently multi-targting .NET Framework (admittedly >=3.5), .NET Core and .NET Standard. I don't see why retargeting the multi-targeting Debuggee project should make a difference. – Zev Spitz Apr 22 '21 at 09:27
  • Not sure at all, but could it be that at some point any of the functions is expecting an actual nullable object (couldn't say why)? It would be weird but the test is easy just `DateTime? dte = DateTime.UtcNow;` – Amo Robb Apr 22 '21 at 14:57
  • @ZevSpitz, hmm, I see. What happens on my side is that I only have .Net Core 2.1, 3,1 and 5 installed, while the Debuggee targets `net35;netcoreapp2.0;netstandard2.0`. I have none, so I guess it just defaults to .Net 3.5. Anyway, I just changed it manually to `netcoreapp2.1`, which helped, but I now get a new error: `..\Debuggee\Debuggee.csproj' targets 'netcoreapp2.1'. It cannot be referenced by a project that targets '.NETFramework,Version=v4.8.`, which is understandable ¯\_(ツ)_/¯ – OfirD Apr 22 '21 at 15:12
  • if you call `objectProvider.GetObject()` before `TransferObject(5);` can you get the Datetime correctly? What happens if you do `if (objectProvider.IsObjectReplaceable) { objectProvider.ReplaceObject((Datetime?)objectProvider.GetObject()); }` before `TransferObject(5);` – Amo Robb Apr 22 '21 at 16:23
  • @OfirD You can't get rid of the multitargeting entirely; don't you have any version of .NET Framework installed? If anything, try `netstandard2.0`. – Zev Spitz Apr 22 '21 at 16:41
  • @AmoRobb Using `DateTime?`, and adding a new `DebuggerVisualizer` attritbute to expose the visualizer on `DateTime?` doesn't help; the debuggee side also only gets `null`. Calling `GetObject` (with the debuggee-side `GetObject` returning the target value) and incorporating the target value in the object I am passing along to `TransferObject`, is actually the workaround I am using, and seems to work well, as I noted. – Zev Spitz Apr 22 '21 at 16:53
  • @AmoRobb When opening the visualizer on the code used in the question (`var dte = DateTime.UtcNow; object o = dte;`) `IsObjectReplaceable` returns `false`, so I can't use `ReplaceObject` here. – Zev Spitz Apr 23 '21 at 05:12
  • some more ideas: a) have you overwritten `VisualizerObjectSource.Serialize`? if yes, could you post it. If not, you could try and check what you're getting as target in that function. b) It bugs me that GetData works for you but transferObject doesn't; I guess you have tried with transferData instead of transferObject, just in case. If you call `objectProvider.GetData()` just before `objectProvider.TransferData(...)`, does the first work and the second fail?. c) just to discard; have you tried `Serialize(outgoingData, target?.GetType().FullName??"null");` replacing your TransferData code – Amo Robb Apr 23 '21 at 08:22
  • @AmoRobb a) `Serialize` is a static method on `VisualizerObjectSource.Serialize` and thus not overridable. But ILSpy reports the implementation like this: `new BinaryFormatter().Serialize(serializationStream, target);`. b) (It bugs me too, to the tune of a 200 point bounty :) ) Per ILSpy, both `TransferData` and `TransferObject` call into the same private method, with `TransferObject` calling `Serialize` on the object first. And the workaround I am currently calls `GetObject` first; the debuggee-sde `TransferData` still doesn't get the original value. c) Tried, still returns `"null"`. – Zev Spitz Apr 23 '21 at 09:12
  • Just a note regarding `ClrCustomVisualizerDebuggeeHost`: The problem is not with `incomingData` - this variable actually gets the correct value, otherwise `repetitions` would have been null. The problem is that `visualizedObject` is null. Correct? – OfirD Apr 23 '21 at 14:05
  • @OfirD Correct. – Zev Spitz Apr 25 '21 at 00:07
  • I also had problems with debugging value types (`Color`, in my case). If I remember well, abandoning the `TransferData` override was the solution in my case (though it didn't solve [everything](https://developercommunity.visualstudio.com/t/visual-sudio-2019-throws-a-nullreferenceexception-1/1142584) as value replacement cannot use custom serialization). But the 'way there' serialization (including any supplementary metadata) should work by overriding `GetData` only ([examples](https://github.com/koszeggy/KGySoft.Drawing.Tools/tree/master/KGySoft.Drawing.DebuggerVisualizers/Serialization)). – György Kőszeg Apr 26 '21 at 19:17
  • @GyörgyKőszeg As someone who has wrestled with this issue previously, what are your thoughts on [this answer/workaround](https://stackoverflow.com/a/67230086/111794)? – Zev Spitz Apr 26 '21 at 19:50
  • I don't really like it. It avoids serialization but you don't need to do so. As a comment it would be a bit longer, maybe I add an answer soon. – György Kőszeg Apr 26 '21 at 20:33

2 Answers2

2

I can't find a proper solution. It could be just a bug introduced in one of the latest versions and nobody has come across this problem with value types so far. Actually, I tried

DateTime dte = DateTime.UtcNow;
ValueType vt = dte;

And again it does work with vt but not with dte. I added a explicit target to net48 just in case but it changed nothing.

The best I could come up with is a workaround very similar to the one I'm guessing Zev Spitz is using, but trying not to waste the GetData override just to get the target value. It's not a very nice solution, I'm afraid.

Provided that you want to use GetData to retrieve a different value but it will be used in your DialogDebuggerVisualizer.Show override, you can just store your value inside the VisualizerObjectSource object when you call GetData and retrieve it when you call TransferData, without actually trasferring it from Debuggee to Debugger.

 public class VisualizerObjectSource : Microsoft.VisualStudio.DebuggerVisualizers.VisualizerObjectSource 
    {
       /*static*/ DateTime? _lastDatetime=null;
        public override void TransferData(object target, Stream incomingData, Stream outgoingData) 
        {
            target = _lastDatetime;

            //Calculate here the output value        
            object toSerialize = " is null = " + (target==null).ToString();
       
            Serialize(outgoingData, toSerialize);
        }

        public override void GetData(object target, Stream outgoingData)
        {
            _lastDatetime = (DateTime)target;
            
            //Calculate here what you want to be returned by GetData
            base.GetData(" The stuff you want to return ", outgoingData);
        }   

    }

and in your Debugger side, make sure you call GetObject/GetData before you call TransferObject()


       protected override void Show(IDialogVisualizerService windowService, 
IVisualizerObjectProvider objectProvider)
        {            
            object MyCustomStuff =objectProvider.GetObject();
            var response = objectProvider.TransferObject(5);

           //[...]          

             string msg =  response .ToString();

            MessageBox.Show(msg);
        }

Amo Robb
  • 810
  • 5
  • 11
  • This does seem to be a reasonable workaround; and better in some respects than what I am currently doing. _"It's not a very nice solution, I'm afraid."_ Short of fixing the problem at its source, I suspect the only solutions are the not-so-nice ones. – Zev Spitz Apr 25 '21 at 00:18
2

As I mentioned in the comments, TransferData is actually not required. Additionally, the whole BinaryFormatter serialization can be avoided (unfortunately only when tranferring the data towards the debugger visualizer and not when replacing an edited value but there is another trick for that).

Firstly, consider the following setup:

[assembly: DebuggerVisualizer(typeof(DateTimeVisualizer), typeof(DateTimeSerializer),
    Target = typeof(DateTime),
    Description = "DateTime Debugger Visualizer")]

Where the serializer is as follows:

// Note that TransferData is not overridden, and we do not call base.GetData so we can 
// avoid using BinaryFormatter (which often has issues when debugging a .NET Core or newer project)
internal class DateTimeSerializer : VisualizerObjectSource
{
    public override void GetData(object target, Stream outgoingData)
    {
       var dateTime = (DateTime)target;

       // Note: do not dispose the writer so the outgoingData remains open.
       // If targeting newer frameworks you can use the leaveOpen parameter, too.
       var writer = new BinaryWriter(outgoingData);

       // What a tiny payload compared to the default BinaryFormatter result...
       writer.Write(dateTime.Ticks);
       writer.Write((int)dateTime.Kind);
    }
}

And in the visualizer itself it is important to not call GetObject, which would try to deserialize your stream by BinaryFormatter. Instead, use GetData, which returns the raw stream:

internal class DateTimeDebuggerVisualizer : DialogDebuggerVisualizer
{
    protected override void Show(IDialogVisualizerService windowService, IVisualizerObjectProvider objectProvider)
    {
        // GetObject would fail here as we have a custom written stream
        var reader = new BinaryReader(objectProvider.GetData());

        var dateTime = new DateTime(reader.ReadInt64(), (DateTimeKind)reader.ReadInt32());

        // Show the debugger [...]
    }

If your debugger can edit the data, then replacing the value is another issue. Unfortunately the BinaryFormatter serialization cannot be avoided in that case. It can be an issue if your object is not serializable in every target (eg. in .NET Core and above).

Of course, this is not an issue in case of DateTime, so just as a side note: in such case the trick can be to put a custom serializable data in ReplaceObject that implements IObjectReference so you can return the custom-built result in IObjectReference.GetRealObject. Here is one such example from my serializers.

György Kőszeg
  • 17,093
  • 6
  • 37
  • 65
  • I need to use `TransferData` in this and other visualizers, to transfer a "settings" object to the debuggee side for producing the response object from the target value. ([example](https://github.com/zspitz/Periscope/blob/d5c8b89c082951c393f790ce4ea69be4561b189a/VisualizerWindowBase.cs#L38), and the specific config looks like [this](https://github.com/zspitz/DateTimeVisualizer/blob/master/Serialization/Config.cs); or a [more elaborate example](https://github.com/zspitz/ExpressionTreeVisualizer/blob/master/Serialization/Config.cs)). – Zev Spitz Apr 26 '21 at 21:42
  • That said, I'm not sure what this answer gives me. As far as I'm concerned, `GetObject` isn't broken, and there's no need to create a `BinaryWriter` and `BinaryReader` to replace `GetObject` with a custom `GetData` path. I usually want to use `GetData`/`GetObject` to return a string unique to the target assembly, so I can store and retrieve assembly-specific settings via the debugger side. – Zev Spitz Apr 26 '21 at 21:53
  • 1
    _"I need to use TransferData in this and other visualizers, to transfer a "settings" object"_ - by using `BinaryReader`/`BinaryWriter` you can write any supplementary metadata in the stream you want. This makes possible to debug non-serializable types as well. [Here](https://github.com/koszeggy/KGySoft.Drawing.Tools/blob/aa7361f8b0514af78b4125d4cca377848d68f5ad/KGySoft.Drawing.DebuggerVisualizers/Serialization/GraphicsSerializationInfo.cs#L66) is an example. Thus you don't need `TransferData` at all. – György Kőszeg Apr 27 '21 at 07:46
  • Could you clarify: AFAICT both your answer and your example show how to create arbitrary data on the **debuggee** side and pass it back to the **debugger** side. How can I send data (other than the debugged value) from the **debugger** side to the **debuggee** side, without using `TransferData` / `TransferObject`? My goal is not to modify the target value, but rather that whatever data comes back to the debugger side might be different based on the data that came originally from the debugger side, together with the target value, – Zev Spitz Apr 27 '21 at 10:26
  • `TransferData` is meant for updating parts of the original object rather than replacing it completely. Considering that `DateTime` is a value type you couldn't really change the original instance anyway because (even if `target` wasn't null) the boxed instance would likely be another copy of the original instance. So in this case I recommend replacing the whole original instance. Normally `ReplaceObject` should work but if you need to transfer back more data or non-serializable types, then the `IReferenceObject` trick may help. – György Kőszeg Apr 27 '21 at 15:05
  • _`TransferData` is meant for updating parts of the original object_ I think `TransferData` is to be used for any type of data transfer, per the [docs](https://learn.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.debuggervisualizers.visualizerobjectsource.transferdata#remarks): _"Transfers data simultaneously in both directions between the debuggee and debugger sides. This data may be any sort of request for the visualizer ... This method can be very useful if you implement a custom messaging scheme."_ I don't think we should limit `TransferData` in the way you are suggesting. – Zev Spitz Apr 27 '21 at 15:18
  • 1
    This was my [reference](https://learn.microsoft.com/en-us/visualstudio/debugger/visualizer-architecture): _"Using one of the Replace methods creates a new data object in the debuggee that replaces the object being visualized. If you want to change the contents of the original object without replacing it, use one of the Transfer methods"._ But you are right, it is just one practical and not exclusive way of using the transfer methods. Anyway, replacing as I described is still one possible solution. – György Kőszeg Apr 27 '21 at 15:34