diff --git a/src/Microsoft.AspNetCore.Blazor.Server/Microsoft.AspNetCore.Blazor.Server.csproj b/src/Microsoft.AspNetCore.Blazor.Server/Microsoft.AspNetCore.Blazor.Server.csproj index 03dd25d053..ad810fad35 100644 --- a/src/Microsoft.AspNetCore.Blazor.Server/Microsoft.AspNetCore.Blazor.Server.csproj +++ b/src/Microsoft.AspNetCore.Blazor.Server/Microsoft.AspNetCore.Blazor.Server.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -8,6 +8,9 @@ + + + diff --git a/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/UpdateSources.cmd b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/UpdateSources.cmd new file mode 100644 index 0000000000..591f2f9950 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/UpdateSources.cmd @@ -0,0 +1,20 @@ +@echo off +echo |---- +echo | Copying the ws-proxy sources here is a temporary step until ws-proxy is +echo | distributed as a NuGet package. +echo | ... +echo | Instead of dealing with Git submodules, this script simply fetches the +echo | latest sources so they can be built directly inside this project (hence +echo | we don't have to publish our own separate package for this). +echo | ... +echo | When updating, you'll need to re-apply any patches we've made manually. +echo |---- +@echo on + +cd /D "%~dp0" +rmdir /s /q ws-proxy +git clone https://github.com/kumpera/ws-proxy.git +rmdir /s /q ws-proxy\.git +del ws-proxy\*.csproj +del ws-proxy\*.sln +del ws-proxy\Program.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/DebugStore.cs b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/DebugStore.cs new file mode 100644 index 0000000000..bb274946e9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/DebugStore.cs @@ -0,0 +1,519 @@ +using System; +using System.IO; +using System.Collections.Generic; +using Mono.Cecil; +using Mono.Cecil.Cil; +using System.Linq; +using Newtonsoft.Json.Linq; +using System.Net.Http; + +namespace WsProxy { + internal class BreakPointRequest { + public string Assembly { get; private set; } + public string File { get; private set; } + public int Line { get; private set; } + public int Column { get; private set; } + + public static BreakPointRequest Parse (JObject args) + { + if (args == null) + return null; + + var url = args? ["url"]?.Value (); + if (!url.StartsWith ("dotnet://", StringComparison.InvariantCulture)) + return null; + + var parts = url.Substring ("dotnet://".Length).Split ('/'); + if (parts.Length != 2) + return null; + + var line = args? ["lineNumber"]?.Value (); + var column = args? ["columnNumber"]?.Value (); + if (line == null || column == null) + return null; + + return new BreakPointRequest () { + Assembly = parts [0], + File = parts [1], + Line = line.Value, + Column = column.Value + }; + } + } + + + internal class VarInfo { + public VarInfo (VariableDebugInformation v) + { + this.Name = v.Name; + this.Index = v.Index; + } + + public VarInfo (ParameterDefinition p) + { + this.Name = p.Name; + this.Index = (p.Index + 1) * -1; + } + public string Name { get; private set; } + public int Index { get; private set; } + + + public override string ToString () + { + return $"(var-info [{Index}] '{Name}')"; + } + } + + + internal class CliLocation { + + private MethodInfo method; + private int offset; + + public CliLocation (MethodInfo method, int offset) + { + this.method = method; + this.offset = offset; + } + + public MethodInfo Method { get => method; } + public int Offset { get => offset; } + } + + + internal class SourceLocation { + SourceId id; + int line; + int column; + CliLocation cliLoc; + + public SourceLocation (SourceId id, int line, int column) + { + this.id = id; + this.line = line; + this.column = column; + } + + public SourceLocation (MethodInfo mi, SequencePoint sp) + { + this.id = mi.SourceId; + this.line = sp.StartLine; + this.column = sp.StartColumn - 1; + this.cliLoc = new CliLocation (mi, sp.Offset); + } + + public SourceId Id { get => id; } + public int Line { get => line; } + public int Column { get => column; } + public CliLocation CliLocation => this.cliLoc; + + public override string ToString () + { + return $"{id}:{Line}:{Column}"; + } + + public static SourceLocation Parse (JObject obj) + { + if (obj == null) + return null; + + var id = SourceId.TryParse (obj ["scriptId"]?.Value ()); + var line = obj ["lineNumber"]?.Value (); + var column = obj ["columnNumber"]?.Value (); + if (id == null || line == null || column == null) + return null; + + return new SourceLocation (id, line.Value, column.Value); + } + + internal JObject ToJObject () + { + return JObject.FromObject (new { + scriptId = id.ToString (), + lineNumber = line, + columnNumber = column + }); + } + + } + + internal class SourceId { + readonly int assembly, document; + + public int Assembly => assembly; + public int Document => document; + + internal SourceId (int assembly, int document) + { + this.assembly = assembly; + this.document = document; + } + + + public SourceId (string id) + { + id = id.Substring ("dotnet://".Length); + var sp = id.Split ('_'); + this.assembly = int.Parse (sp [0]); + this.document = int.Parse (sp [1]); + } + + public static SourceId TryParse (string id) + { + if (!id.StartsWith ("dotnet://", StringComparison.InvariantCulture)) + return null; + return new SourceId (id); + + } + public override string ToString () + { + return $"dotnet://{assembly}_{document}"; + } + + public override bool Equals (object obj) + { + if (obj == null) + return false; + SourceId that = obj as SourceId; + return that.assembly == this.assembly && that.document == this.document; + } + + public override int GetHashCode () + { + return this.assembly.GetHashCode () ^ this.document.GetHashCode (); + } + + public static bool operator == (SourceId a, SourceId b) + { + if ((object)a == null) + return (object)b == null; + return a.Equals (b); + } + + public static bool operator != (SourceId a, SourceId b) + { + return !a.Equals (b); + } + } + + internal class MethodInfo { + AssemblyInfo assembly; + internal MethodDefinition methodDef; + SourceFile source; + + public SourceId SourceId => source.SourceId; + + public string Name => methodDef.Name; + + public SourceLocation StartLocation { get; private set; } + public SourceLocation EndLocation { get; private set; } + public AssemblyInfo Assembly => assembly; + public int Token => (int)methodDef.MetadataToken.RID; + + public MethodInfo (AssemblyInfo assembly, MethodDefinition methodDef, SourceFile source) + { + this.assembly = assembly; + this.methodDef = methodDef; + this.source = source; + + var sps = methodDef.DebugInformation.SequencePoints; + if (sps != null && sps.Count > 0) { + StartLocation = new SourceLocation (this, sps [0]); + EndLocation = new SourceLocation (this, sps [sps.Count - 1]); + } + + } + + public SourceLocation GetLocationByIl (int pos) + { + SequencePoint prev = null; + foreach (var sp in methodDef.DebugInformation.SequencePoints) { + if (sp.Offset > pos) + break; + prev = sp; + } + + if (prev != null) + return new SourceLocation (this, prev); + + return null; + } + + public VarInfo [] GetLiveVarsAt (int offset) + { + var res = new List (); + + res.AddRange (methodDef.Parameters.Select (p => new VarInfo (p))); + + res.AddRange (methodDef.DebugInformation.GetScopes () + .Where (s => s.Start.Offset <= offset && (s.End.IsEndOfMethod || s.End.Offset > offset)) + .SelectMany (s => s.Variables) + .Where (v => !v.IsDebuggerHidden) + .Select (v => new VarInfo (v))); + + + return res.ToArray (); + } + } + + + internal class AssemblyInfo { + static int next_id; + ModuleDefinition image; + readonly int id; + Dictionary methods = new Dictionary (); + readonly List sources = new List (); + + public AssemblyInfo (byte[] assembly, byte[] pdb) + { + lock (typeof (AssemblyInfo)) { + this.id = ++next_id; + } + + ReaderParameters rp = new ReaderParameters (/*ReadingMode.Immediate*/); + if (pdb != null) { + rp.ReadSymbols = true; + rp.SymbolReaderProvider = new PortablePdbReaderProvider (); + rp.SymbolStream = new MemoryStream (pdb); + } + + rp.InMemory = true; + + this.image = ModuleDefinition.ReadModule (new MemoryStream (assembly), rp); + + Populate (); + } + + public AssemblyInfo () + { + } + + void Populate () + { + var d2s = new Dictionary (); + + Func get_src = (doc) => { + if (doc == null) + return null; + if (d2s.ContainsKey (doc)) + return d2s [doc]; + var src = new SourceFile (this, sources.Count, doc); + sources.Add (src); + d2s [doc] = src; + return src; + }; + + foreach (var m in image.GetTypes ().SelectMany (t => t.Methods)) { + Document first_doc = null; + foreach (var sp in m.DebugInformation.SequencePoints) { + if (first_doc == null) { + first_doc = sp.Document; + } else if (first_doc != sp.Document) { + //FIXME this is needed for (c)ctors in corlib + throw new Exception ($"Cant handle multi-doc methods in {m}"); + } + } + + var src = get_src (first_doc); + var mi = new MethodInfo (this, m, src); + int mt = (int)m.MetadataToken.RID; + this.methods [mt] = mi; + if (src != null) + src.AddMethod (mi); + + } + } + + public IEnumerable Sources { + get { return this.sources; } + } + + public int Id => id; + public string Name => image.Name; + + public SourceFile GetDocById (int document) + { + return sources.FirstOrDefault (s => s.SourceId.Document == document); + } + + public MethodInfo GetMethodByToken (int token) + { + return methods [token]; + } + + } + + internal class SourceFile { + HashSet methods; + AssemblyInfo assembly; + int id; + Document doc; + + internal SourceFile (AssemblyInfo assembly, int id, Document doc) + { + this.methods = new HashSet (); + this.assembly = assembly; + this.id = id; + this.doc = doc; + } + + internal void AddMethod (MethodInfo mi) + { + this.methods.Add (mi); + } + public string FileName => Path.GetFileName (doc.Url); + public string Url => $"dotnet://{assembly.Name}/{FileName}"; + public string DocHashCode => "abcdee" + id; + public SourceId SourceId => new SourceId (assembly.Id, this.id); + public string LocalPath => doc.Url; + + public IEnumerable Methods => this.methods; + } + + internal class DebugStore { + List assemblies = new List (); + + public DebugStore (string[] loaded_files) + { + bool MatchPdb (string asm, string pdb) { + return Path.ChangeExtension (asm, "pdb") == pdb; + } + + var asm_files = new List (); + var pdb_files = new List (); + foreach (var f in loaded_files) { + var file_name = f.ToLower (); + if (file_name.EndsWith (".pdb", StringComparison.Ordinal)) + pdb_files.Add (file_name); + else + asm_files.Add (file_name); + } + + //FIXME make this parallel + foreach (var p in asm_files) { + var pdb = pdb_files.FirstOrDefault (n => MatchPdb (p, n)); + HttpClient h = new HttpClient (); + var assembly_bytes = h.GetByteArrayAsync (p).Result; + byte[] pdb_bytes = null; + if (pdb != null) + pdb_bytes = h.GetByteArrayAsync (pdb).Result; + + this.assemblies.Add (new AssemblyInfo (assembly_bytes, pdb_bytes)); + } + } + + public IEnumerable AllSources () + { + foreach (var a in assemblies) { + foreach (var s in a.Sources) + yield return s; + } + + } + + public SourceFile GetFileById (SourceId id) + { + return AllSources ().FirstOrDefault (f => f.SourceId.Equals (id)); + } + + public AssemblyInfo GetAssemblyByName (string name) + { + return assemblies.FirstOrDefault (a => a.Name.Equals (name, StringComparison.InvariantCultureIgnoreCase)); + } + + /* + Matching logic here is hilarious and it goes like this: + We inject one line at the top of all sources to make it easy to identify them [1]. + V8 uses zero based indexing for both line and column. + PPDBs uses one based indexing for both line and column. + Which means that: + - for lines, values are already adjusted (v8 numbers come +1 due to the injected line) + - for columns, we need to +1 the v8 numbers + [1] It's so we can deal with the Runtime.compileScript ide cmd + */ + static bool Match (SequencePoint sp, SourceLocation start, SourceLocation end) + { + if (start.Line > sp.StartLine) + return false; + if ((start.Column + 1) > sp.StartColumn && start.Line == sp.StartLine) + return false; + + if (end.Line < sp.EndLine) + return false; + + if ((end.Column + 1) < sp.EndColumn && end.Line == sp.EndLine) + return false; + + return true; + } + + public List FindPossibleBreakpoints (SourceLocation start, SourceLocation end) + { + //XXX FIXME no idea what todo with locations on different files + if (start.Id != end.Id) + return null; + var src_id = start.Id; + + var doc = GetFileById (src_id); + + var res = new List (); + foreach (var m in doc.Methods) { + foreach (var sp in m.methodDef.DebugInformation.SequencePoints) { + if (Match (sp, start, end)) + res.Add (new SourceLocation (m, sp)); + } + } + + return res; + } + + /* + Matching logic here is hilarious and it goes like this: + We inject one line at the top of all sources to make it easy to identify them [1]. + V8 uses zero based indexing for both line and column. + PPDBs uses one based indexing for both line and column. + Which means that: + - for lines, values are already adjusted (v8 numbers come + 1 due to the injected line) + - for columns, we need to +1 the v8 numbers + [1] It's so we can deal with the Runtime.compileScript ide cmd + */ + static bool Match (SequencePoint sp, int line, int column) + { + if (sp.StartLine > line || sp.EndLine < line) + return false; + + //Chrome sends a zero column even if getPossibleBreakpoints say something else + if (column == 0) + return true; + + if (sp.StartColumn > (column + 1) && sp.StartLine == line) + return false; + + if (sp.EndColumn < (column + 1) && sp.EndLine == line) + return false; + + return true; + } + + public SourceLocation FindBestBreakpoint (BreakPointRequest req) + { + var asm = this.assemblies.FirstOrDefault (a => a.Name == req.Assembly); + var src = asm.Sources.FirstOrDefault (s => s.FileName == req.File); + + foreach (var m in src.Methods) { + foreach (var sp in m.methodDef.DebugInformation.SequencePoints) { + //FIXME handle multi doc methods + if (Match (sp, req.Line, req.Column)) + return new SourceLocation (m, sp); + } + } + + return null; + } + + public string ToUrl (SourceLocation location) + { + return GetFileById (location.Id).Url; + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/MonoProxy.cs b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/MonoProxy.cs new file mode 100644 index 0000000000..2f19e40924 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/MonoProxy.cs @@ -0,0 +1,590 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; + +using System.Net.WebSockets; +using System.Threading; +using System.IO; +using System.Text; +using System.Collections.Generic; + +namespace WsProxy { + + internal class MonoCommands { + public const string GET_CALL_STACK = "MONO.mono_wasm_get_call_stack()"; + public const string IS_RUNTIME_READY_VAR = "MONO.mono_wasm_runtime_is_ready"; + public const string START_SINGLE_STEPPING = "MONO.mono_wasm_start_single_stepping({0})"; + public const string GET_SCOPE_VARIABLES = "MONO.mono_wasm_get_variables({0}, [ {1} ])"; + public const string SET_BREAK_POINT = "MONO.mono_wasm_set_breakpoint(\"{0}\", {1}, {2})"; + public const string GET_LOADED_FILES = "MONO.mono_wasm_get_loaded_files()"; + public const string CLEAR_ALL_BREAKPOINTS = "MONO.mono_wasm_clear_all_breakpoints()"; + } + + internal class MonoConstants { + public const string RUNTIME_IS_READY = "mono_wasm_runtime_ready"; + } + class Frame { + public Frame (MethodInfo method, SourceLocation location, int id) + { + this.Method = method; + this.Location = location; + this.Id = id; + } + + public MethodInfo Method { get; private set; } + public SourceLocation Location { get; private set; } + public int Id { get; private set; } + } + + + class Breakpoint { + public SourceLocation Location { get; private set; } + public int LocalId { get; private set; } + public int RemoteId { get; set; } + public BreakPointState State { get; set; } + + public Breakpoint (SourceLocation loc, int localId, BreakPointState state) + { + this.Location = loc; + this.LocalId = localId; + this.State = state; + } + } + + enum BreakPointState { + Active, + Disabled, + Pending + } + + enum StepKind { + Into, + Out, + Over + } + + internal class MonoProxy : WsProxy { + DebugStore store; + List breakpoints = new List (); + List current_callstack; + bool runtime_ready; + int local_breakpoint_id; + int ctx_id; + JObject aux_ctx_data; + + public MonoProxy () { } + + protected override async Task AcceptEvent (string method, JObject args, CancellationToken token) + { + switch (method) { + case "Runtime.executionContextCreated": { + var ctx = args? ["context"]; + var aux_data = ctx? ["auxData"] as JObject; + if (aux_data != null) { + var is_default = aux_data ["isDefault"]?.Value (); + if (is_default == true) { + var ctx_id = ctx ["id"].Value (); + await OnDefaultContext (ctx_id, aux_data, token); + } + } + break; + } + case "Debugger.paused": { + //TODO figure out how to stich out more frames and, in particular what happens when real wasm is on the stack + var top_func = args? ["callFrames"]? [0]? ["functionName"]?.Value (); + if (top_func == "mono_wasm_fire_bp" || top_func == "_mono_wasm_fire_bp") { + await OnBreakPointHit (args, token); + return true; + } + if (top_func == MonoConstants.RUNTIME_IS_READY) { + await OnRuntimeReady (token); + return true; + } + break; + } + case "Debugger.scriptParsed":{ + if (args?["url"]?.Value ()?.StartsWith ("wasm://") == true) { + // Console.WriteLine ("ignoring wasm event"); + return true; + } + break; + } + } + + return false; + } + + + protected override async Task AcceptCommand (int id, string method, JObject args, CancellationToken token) + { + switch (method) { + case "Debugger.getScriptSource": { + var script_id = args? ["scriptId"]?.Value (); + if (script_id.StartsWith ("dotnet://", StringComparison.InvariantCultureIgnoreCase)) { + OnGetScriptSource (id, script_id, token); + return true; + } + + break; + } + case "Runtime.compileScript": { + var exp = args? ["expression"]?.Value (); + if (exp.StartsWith ("//dotnet:", StringComparison.InvariantCultureIgnoreCase)) { + OnCompileDotnetScript (id, token); + return true; + } + break; + } + + case "Debugger.getPossibleBreakpoints": { + var start = SourceLocation.Parse (args? ["start"] as JObject); + //FIXME support variant where restrictToFunction=true and end is omitted + var end = SourceLocation.Parse (args? ["end"] as JObject); + if (start != null && end != null) + return GetPossibleBreakpoints (id, start, end, token); + break; + } + + case "Debugger.setBreakpointByUrl": { + Info ($"BP req {args}"); + var bp_req = BreakPointRequest.Parse (args); + if (bp_req != null) { + await SetBreakPoint (id, bp_req, token); + return true; + } + break; + } + + case "Debugger.resume": { + await OnResume (token); + break; + } + + case "Debugger.stepInto": { + if (this.current_callstack != null) { + await Step (id, StepKind.Into, token); + return true; + } + break; + } + + case "Debugger.stepOut": { + if (this.current_callstack != null) { + await Step (id, StepKind.Out, token); + return true; + } + break; + } + + case "Debugger.stepOver": { + if (this.current_callstack != null) { + await Step (id, StepKind.Over, token); + return true; + } + break; + } + + case "Runtime.getProperties": { + var objId = args? ["objectId"]?.Value (); + if (objId.StartsWith ("dotnet:scope:", StringComparison.InvariantCulture)) { + await GetScopeProperties (id, int.Parse (objId.Substring ("dotnet:scope:".Length)), token); + return true; + } + + break; + } + } + + return false; + } + + async Task OnRuntimeReady (CancellationToken token) + { + Info ("RUNTIME READY, PARTY TIME"); + await RuntimeReady (token); + await SendCommand ("Debugger.resume", new JObject (), token); + } + + async Task OnBreakPointHit (JObject args, CancellationToken token) + { + //FIXME we should send release objects every now and then? Or intercept those we inject and deal in the runtime + var o = JObject.FromObject (new { + expression = MonoCommands.GET_CALL_STACK, + objectGroup = "mono_debugger", + includeCommandLineAPI = false, + silent = false, + returnByValue = true + }); + + var orig_callframes = args? ["callFrames"]?.Values (); + var res = await SendCommand ("Runtime.evaluate", o, token); + + if (res.IsErr) { + //Give up and send the original call stack + SendEvent ("Debugger.paused", args, token); + return; + } + + //step one, figure out where did we hit + //lol no, fuck it, let's use fake data + var res_value = res.Value? ["result"]? ["value"]; + if (res_value == null || res_value is JValue) { + //Give up and send the original call stack + SendEvent ("Debugger.paused", args, token); + return; + } + + Debug ($"call stack (err is {res.Error} value is:\n{res.Value}"); + var bp_id = res_value? ["breakpoint_id"]?.Value (); + Debug ($"We just hit bp {bp_id}"); + if (!bp_id.HasValue) { + //Give up and send the original call stack + SendEvent ("Debugger.paused", args, token); + return; + } + var bp = this.breakpoints.FirstOrDefault (b => b.RemoteId == bp_id.Value); + + var src = bp == null ? null : store.GetFileById (bp.Location.Id); + + var callFrames = new List (); + foreach (var f in orig_callframes) { + var function_name = f ["functionName"]?.Value (); + var url = f ["url"]?.Value (); + if ("mono_wasm_fire_bp" == function_name || "_mono_wasm_fire_bp" == function_name) { + var frames = new List (); + int frame_id = 0; + var the_mono_frames = res.Value? ["result"]? ["value"]? ["frames"]?.Values (); + foreach (var mono_frame in the_mono_frames) { + var il_pos = mono_frame ["il_pos"].Value (); + var method_token = mono_frame ["method_token"].Value (); + var assembly_name = mono_frame ["assembly_name"].Value (); + + var asm = store.GetAssemblyByName (assembly_name); + var method = asm.GetMethodByToken (method_token); + var location = method.GetLocationByIl (il_pos); + + Info ($"frame il offset: {il_pos} method token: {method_token} assembly name: {assembly_name}"); + Info ($"\tmethod {method.Name} location: {location}"); + frames.Add (new Frame (method, location, frame_id)); + + callFrames.Add (JObject.FromObject (new { + functionName = method.Name, + + functionLocation = method.StartLocation.ToJObject (), + + location = location.ToJObject (), + + url = store.ToUrl (location), + + scopeChain = new [] { + new { + type = "local", + @object = new { + @type = "object", + className = "Object", + description = "Object", + objectId = $"dotnet:scope:{frame_id}" + }, + name = method.Name, + startLocation = method.StartLocation.ToJObject (), + endLocation = method.EndLocation.ToJObject (), + } + }, + + @this = new { + } + })); + + ++frame_id; + this.current_callstack = frames; + } + } else if (!url.StartsWith ("wasm://wasm/", StringComparison.InvariantCulture)) { + callFrames.Add (f); + } + } + + var bp_list = new string [bp == null ? 0 : 1]; + if (bp != null) + bp_list [0] = $"dotnet:{bp.LocalId}"; + + o = JObject.FromObject (new { + callFrames = callFrames, + reason = "other", //other means breakpoint + hitBreakpoints = bp_list, + }); + + SendEvent ("Debugger.paused", o, token); + } + + async Task OnDefaultContext (int ctx_id, JObject aux_data, CancellationToken token) + { + Debug ("Default context created, clearing state and sending events"); + + //reset all bps + foreach (var b in this.breakpoints){ + b.State = BreakPointState.Pending; + } + this.runtime_ready = false; + + var o = JObject.FromObject (new { + expression = MonoCommands.IS_RUNTIME_READY_VAR, + objectGroup = "mono_debugger", + includeCommandLineAPI = false, + silent = false, + returnByValue = true + }); + this.ctx_id = ctx_id; + this.aux_ctx_data = aux_data; + + Debug ("checking if the runtime is ready"); + var res = await SendCommand ("Runtime.evaluate", o, token); + var is_ready = res.Value? ["result"]? ["value"]?.Value (); + //Debug ($"\t{is_ready}"); + if (is_ready.HasValue && is_ready.Value == true) { + Debug ("RUNTIME LOOK READY. GO TIME!"); + await RuntimeReady (token); + } + } + + + async Task OnResume (CancellationToken token) + { + //discard frames + this.current_callstack = null; + await Task.CompletedTask; + } + + async Task Step (int msg_id, StepKind kind, CancellationToken token) + { + + var o = JObject.FromObject (new { + expression = string.Format (MonoCommands.START_SINGLE_STEPPING, (int)kind), + objectGroup = "mono_debugger", + includeCommandLineAPI = false, + silent = false, + returnByValue = true, + }); + + var res = await SendCommand ("Runtime.evaluate", o, token); + + SendResponse (msg_id, Result.Ok (new JObject ()), token); + + this.current_callstack = null; + + await SendCommand ("Debugger.resume", new JObject (), token); + } + + async Task GetScopeProperties (int msg_id, int scope_id, CancellationToken token) + { + var scope = this.current_callstack.FirstOrDefault (s => s.Id == scope_id); + var vars = scope.Method.GetLiveVarsAt (scope.Location.CliLocation.Offset); + + + var var_ids = string.Join (",", vars.Select (v => v.Index)); + + var o = JObject.FromObject (new { + expression = string.Format (MonoCommands.GET_SCOPE_VARIABLES, scope.Id, var_ids), + objectGroup = "mono_debugger", + includeCommandLineAPI = false, + silent = false, + returnByValue = true, + }); + + var res = await SendCommand ("Runtime.evaluate", o, token); + + //if we fail we just buble that to the IDE (and let it panic over it) + if (res.IsErr) { + SendResponse (msg_id, res, token); + return; + } + + var values = res.Value? ["result"]? ["value"]?.Values ().ToArray (); + + var var_list = new List (); + for (int i = 0; i < vars.Length; ++i) { + var_list.Add (JObject.FromObject (new { + name = vars [i].Name, + value = values [i] ["value"] + })); + + } + o = JObject.FromObject (new { + result = var_list + }); + + SendResponse (msg_id, Result.Ok (o), token); + } + + async Task EnableBreakPoint (Breakpoint bp, CancellationToken token) + { + var asm_name = bp.Location.CliLocation.Method.Assembly.Name.ToLower (); + var method_token = bp.Location.CliLocation.Method.Token; + var il_offset = bp.Location.CliLocation.Offset; + + var o = JObject.FromObject (new { + expression = string.Format (MonoCommands.SET_BREAK_POINT, asm_name, method_token, il_offset), + objectGroup = "mono_debugger", + includeCommandLineAPI = false, + silent = false, + returnByValue = true, + }); + + var res = await SendCommand ("Runtime.evaluate", o, token); + var ret_code = res.Value? ["result"]? ["value"]?.Value (); + + if (ret_code.HasValue) { + bp.RemoteId = ret_code.Value; + bp.State = BreakPointState.Active; + //Debug ($"BP local id {bp.LocalId} enabled with remote id {bp.RemoteId}"); + } + + return res; + } + + async Task RuntimeReady (CancellationToken token) + { + + var o = JObject.FromObject (new { + expression = MonoCommands.GET_LOADED_FILES, + objectGroup = "mono_debugger", + includeCommandLineAPI = false, + silent = false, + returnByValue = true, + }); + var loaded_pdbs = await SendCommand ("Runtime.evaluate", o, token); + var the_value = loaded_pdbs.Value? ["result"]? ["value"]; + var the_pdbs = the_value?.ToObject (); + this.store = new DebugStore (the_pdbs); + + foreach (var s in store.AllSources ()) { + var ok = JObject.FromObject (new { + scriptId = s.SourceId.ToString (), + url = s.Url, + executionContextId = this.ctx_id, + hash = s.DocHashCode, + executionContextAuxData = this.aux_ctx_data + }); + //Debug ($"\tsending {s.Url}"); + SendEvent ("Debugger.scriptParsed", ok, token); + } + + o = JObject.FromObject (new { + expression = MonoCommands.CLEAR_ALL_BREAKPOINTS, + objectGroup = "mono_debugger", + includeCommandLineAPI = false, + silent = false, + returnByValue = true, + }); + + var clear_result = await SendCommand ("Runtime.evaluate", o, token); + if (clear_result.IsErr) { + Debug ($"Failed to clear breakpoints due to {clear_result}"); + } + + + runtime_ready = true; + + foreach (var bp in breakpoints) { + if (bp.State != BreakPointState.Pending) + continue; + var res = await EnableBreakPoint (bp, token); + var ret_code = res.Value? ["result"]? ["value"]?.Value (); + + //if we fail we just buble that to the IDE (and let it panic over it) + if (!ret_code.HasValue) { + //FIXME figure out how to inform the IDE of that. + Info ($"FAILED TO ENABLE BP {bp.LocalId}"); + bp.State = BreakPointState.Disabled; + } + } + } + + async Task SetBreakPoint (int msg_id, BreakPointRequest req, CancellationToken token) + { + var bp_loc = store.FindBestBreakpoint (req); + Info ($"BP request for '{req}' runtime ready {runtime_ready}"); + + Breakpoint bp = null; + if (!runtime_ready) { + bp = new Breakpoint (bp_loc, local_breakpoint_id++, BreakPointState.Pending); + } else { + bp = new Breakpoint (bp_loc, local_breakpoint_id++, BreakPointState.Disabled); + + var res = await EnableBreakPoint (bp, token); + var ret_code = res.Value? ["result"]? ["value"]?.Value (); + + //if we fail we just buble that to the IDE (and let it panic over it) + if (!ret_code.HasValue) { + SendResponse (msg_id, res, token); + return; + } + } + + var locations = new List (); + + locations.Add (JObject.FromObject (new { + scriptId = bp_loc.Id.ToString (), + lineNumber = bp_loc.Line, + columnNumber = bp_loc.Column + })); + + breakpoints.Add (bp); + + var ok = JObject.FromObject (new { + breakpointId = $"dotnet:{bp.LocalId}", + locations = locations, + }); + + SendResponse (msg_id, Result.Ok (ok), token); + } + + bool GetPossibleBreakpoints (int msg_id, SourceLocation start, SourceLocation end, CancellationToken token) + { + var bps = store.FindPossibleBreakpoints (start, end); + if (bps == null) + return false; + + var loc = new List (); + foreach (var b in bps) { + loc.Add (b.ToJObject ()); + } + + var o = JObject.FromObject (new { + locations = loc + }); + + SendResponse (msg_id, Result.Ok (o), token); + + return true; + } + + void OnCompileDotnetScript (int msg_id, CancellationToken token) + { + var o = JObject.FromObject (new { }); + + SendResponse (msg_id, Result.Ok (o), token); + + } + + void OnGetScriptSource (int msg_id, string script_id, CancellationToken token) + { + var id = new SourceId (script_id); + var src_file = store.GetFileById (id); + + var res = new StringWriter (); + res.WriteLine ($"//dotnet:{id}"); + + using (var f = new StreamReader (File.Open (src_file.LocalPath, FileMode.Open))) { + res.Write (f.ReadToEnd ()); + } + + var o = JObject.FromObject (new { + scriptSource = res.ToString () + }); + + SendResponse (msg_id, Result.Ok (o), token); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/README.md b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/README.md new file mode 100644 index 0000000000..66bd02c512 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/README.md @@ -0,0 +1,13 @@ +# Running the proxy + +Hack your way around, good luck + +# TODO + +Reconnects - reconsile with all existing breakpoints in the runtime +F5 - recognize it and reset debuger state +release object groups - we don't send for the groups we create nor do we intercept the fake ones we generate for the runtime +Deal with the browser caching stuff across execution - cache invalidation and proper bundling of resources +Figure out how to enable/disable debugging +Do some target validation - right now we simply assume the debugee has a mono runtime in debug mode + diff --git a/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/Startup.cs b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/Startup.cs new file mode 100644 index 0000000000..dbdf77d60a --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/Startup.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Routing; +using System.Net.WebSockets; +using System.Threading; +using System.IO; +using System.Text; + +namespace WsProxy +{ + + internal class Startup { + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices (IServiceCollection services) + { + services.AddRouting (); + } + + public static async Task ProxyMsg (string desc, WebSocket from, WebSocket to) + { + byte [] buff = new byte [4000]; + var mem = new MemoryStream (); + while (true) { + var result = await from.ReceiveAsync (new ArraySegment (buff), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) { + await to.SendAsync (new ArraySegment (mem.GetBuffer (), 0, (int)mem.Length), WebSocketMessageType.Close, true, CancellationToken.None); + return; + } + + if (result.EndOfMessage) { + mem.Write (buff, 0, result.Count); + + var str = Encoding.UTF8.GetString (mem.GetBuffer (), 0, (int)mem.Length); + + await to.SendAsync (new ArraySegment (mem.GetBuffer (), 0, (int)mem.Length), WebSocketMessageType.Text, true, CancellationToken.None); + mem.SetLength (0); + } else { + mem.Write (buff, 0, result.Count); + } + } + } + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure (IApplicationBuilder app, IHostingEnvironment env) + { + //loggerFactory.AddConsole(); + //loggerFactory.AddDebug(); + //app.UseDeveloperExceptionPage (); + + app.UseWebSockets (); app.UseRouter (router => { + router.MapGet ("devtools/page/{pageId}", async context => { + if (!context.WebSockets.IsWebSocketRequest) { + context.Response.StatusCode = 400; + return; + } + + try { + var proxy = new MonoProxy (); + await proxy.Run (context); + } catch (Exception e) { + Console.WriteLine ("got exception {0}", e); + } + }); + }); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/WsProxy.cs b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/WsProxy.cs new file mode 100644 index 0000000000..688b86fa53 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/MonoDebugProxy/ws-proxy/WsProxy.cs @@ -0,0 +1,331 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; + +using System.Net.WebSockets; +using System.Threading; +using System.IO; +using System.Text; +using System.Collections.Generic; + +namespace WsProxy { + + internal struct Result { + public JObject Value { get; private set; } + public JObject Error { get; private set; } + + public bool IsOk => Value != null; + public bool IsErr => Error != null; + + Result (JObject result, JObject error) + { + this.Value = result; + this.Error = error; + } + + public static Result FromJson (JObject obj) + { + return new Result (obj ["result"] as JObject, obj ["error"] as JObject); + } + + public static Result Ok (JObject ok) + { + return new Result (ok, null); + } + + public static Result Err (JObject err) + { + return new Result (null, err); + } + + public JObject ToJObject (int id) { + if (IsOk) { + return JObject.FromObject (new { + id = id, + result = Value + }); + } else { + return JObject.FromObject (new { + id = id, + error = Error + }); + } + } + } + + class WsQueue { + Task current_send; + List pending; + + public WebSocket Ws { get; private set; } + public Task CurrentSend { get { return current_send; } } + public WsQueue (WebSocket sock) + { + this.Ws = sock; + pending = new List (); + } + + public Task Send (byte [] bytes, CancellationToken token) + { + pending.Add (bytes); + if (pending.Count == 1) { + if (current_send != null) + throw new Exception ("WTF, current_send MUST BE NULL IF THERE'S no pending send"); + //Console.WriteLine ("sending {0} bytes", bytes.Length); + current_send = Ws.SendAsync (new ArraySegment (bytes), WebSocketMessageType.Text, true, token); + return current_send; + } + return null; + } + + public Task Pump (CancellationToken token) + { + current_send = null; + pending.RemoveAt (0); + + if (pending.Count > 0) { + if (current_send != null) + throw new Exception ("WTF, current_send MUST BE NULL IF THERE'S no pending send"); + //Console.WriteLine ("sending more {0} bytes", pending[0].Length); + current_send = Ws.SendAsync (new ArraySegment (pending [0]), WebSocketMessageType.Text, true, token); + return current_send; + } + return null; + } + } + + internal class WsProxy { + TaskCompletionSource side_exception = new TaskCompletionSource (); + List<(int, TaskCompletionSource)> pending_cmds = new List<(int, TaskCompletionSource)> (); + ClientWebSocket browser; + WebSocket ide; + int next_cmd_id; + List pending_ops = new List (); + List queues = new List (); + + protected virtual Task AcceptEvent (string method, JObject args, CancellationToken token) + { + return Task.FromResult (false); + } + + protected virtual Task AcceptCommand (int id, string method, JObject args, CancellationToken token) + { + return Task.FromResult (false); + } + + Uri GetBrowserUri (string path) + { + return new Uri ("ws://localhost:9222" + path); + } + + async Task ReadOne (WebSocket socket, CancellationToken token) + { + byte [] buff = new byte [4000]; + var mem = new MemoryStream (); + while (true) { + var result = await socket.ReceiveAsync (new ArraySegment (buff), token); + if (result.MessageType == WebSocketMessageType.Close) { + return null; + } + + if (result.EndOfMessage) { + mem.Write (buff, 0, result.Count); + return Encoding.UTF8.GetString (mem.GetBuffer (), 0, (int)mem.Length); + } else { + mem.Write (buff, 0, result.Count); + } + } + } + + + + WsQueue GetQueueForSocket (WebSocket ws) + { + return queues.FirstOrDefault (q => q.Ws == ws); + } + + WsQueue GetQueueForTask (Task task) { + return queues.FirstOrDefault (q => q.CurrentSend == task); + } + + void Send (WebSocket to, JObject o, CancellationToken token) + { + var bytes = Encoding.UTF8.GetBytes (o.ToString ()); + + var queue = GetQueueForSocket (to); + var task = queue.Send (bytes, token); + if (task != null) + pending_ops.Add (task); + } + + async Task OnEvent (string method, JObject args, CancellationToken token) + { + try { + if (!await AcceptEvent (method, args, token)) { + //Console.WriteLine ("proxy browser: {0}::{1}",method, args); + SendEventInternal (method, args, token); + } + } catch (Exception e) { + side_exception.TrySetException (e); + } + } + + async Task OnCommand (int id, string method, JObject args, CancellationToken token) + { + try { + if (!await AcceptCommand (id, method, args, token)) { + var res = await SendCommandInternal (method, args, token); + SendResponseInternal (id, res, token); + } + } catch (Exception e) { + side_exception.TrySetException (e); + } + } + + void OnResponse (int id, Result result) + { + //Console.WriteLine ("got id {0} res {1}", id, result); + var idx = pending_cmds.FindIndex (e => e.Item1 == id); + var item = pending_cmds [idx]; + pending_cmds.RemoveAt (idx); + + item.Item2.SetResult (result); + } + + void ProcessBrowserMessage (string msg, CancellationToken token) + { + // Debug ($"browser: {msg}"); + var res = JObject.Parse (msg); + + if (res ["id"] == null) + pending_ops.Add (OnEvent (res ["method"].Value (), res ["params"] as JObject, token)); + else + OnResponse (res ["id"].Value (), Result.FromJson (res)); + } + + void ProcessIdeMessage (string msg, CancellationToken token) + { + var res = JObject.Parse (msg); + + pending_ops.Add (OnCommand (res ["id"].Value (), res ["method"].Value (), res ["params"] as JObject, token)); + } + + public async Task SendCommand (string method, JObject args, CancellationToken token) { + Debug ($"sending command {method}: {args}"); + return await SendCommandInternal (method, args, token); + } + + Task SendCommandInternal (string method, JObject args, CancellationToken token) + { + int id = ++next_cmd_id; + + var o = JObject.FromObject (new { + id = id, + method = method, + @params = args + }); + var tcs = new TaskCompletionSource (); + //Console.WriteLine ("add cmd id {0}", id); + pending_cmds.Add ((id, tcs)); + + Send (this.browser, o, token); + return tcs.Task; + } + + public void SendEvent (string method, JObject args, CancellationToken token) + { + //Debug ($"sending event {method}: {args}"); + SendEventInternal (method, args, token); + } + + void SendEventInternal (string method, JObject args, CancellationToken token) + { + var o = JObject.FromObject (new { + method = method, + @params = args + }); + + Send (this.ide, o, token); + } + + public void SendResponse (int id, Result result, CancellationToken token) + { + //Debug ($"sending response: {id}: {result.ToJObject (id)}"); + SendResponseInternal (id, result, token); + } + + void SendResponseInternal (int id, Result result, CancellationToken token) + { + JObject o = result.ToJObject (id); + + Send (this.ide, o, token); + } + + public async Task Run (HttpContext context) + { + var browserUri = GetBrowserUri (context.Request.Path.ToString ()); + Debug ("wsproxy start"); + using (this.ide = await context.WebSockets.AcceptWebSocketAsync ()) { + Debug ("ide connected"); + queues.Add (new WsQueue (this.ide)); + using (this.browser = new ClientWebSocket ()) { + this.browser.Options.KeepAliveInterval = Timeout.InfiniteTimeSpan; + await this.browser.ConnectAsync (browserUri, CancellationToken.None); + queues.Add (new WsQueue (this.browser)); + + Debug ("client connected"); + var x = new CancellationTokenSource (); + + pending_ops.Add (ReadOne (browser, x.Token)); + pending_ops.Add (ReadOne (ide, x.Token)); + pending_ops.Add (side_exception.Task); + + try { + while (!x.IsCancellationRequested) { + var task = await Task.WhenAny (pending_ops); + //Console.WriteLine ("pump {0} {1}", task, pending_ops.IndexOf (task)); + if (task == pending_ops [0]) { + var msg = ((Task)task).Result; + pending_ops [0] = ReadOne (browser, x.Token); //queue next read + ProcessBrowserMessage (msg, x.Token); + } else if (task == pending_ops [1]) { + var msg = ((Task)task).Result; + pending_ops [1] = ReadOne (ide, x.Token); //queue next read + ProcessIdeMessage (msg, x.Token); + } else if (task == pending_ops [2]) { + var res = ((Task)task).Result; + throw new Exception ("side task must always complete with an exception, what's going on???"); + } else { + //must be a background task + pending_ops.Remove (task); + var queue = GetQueueForTask (task); + if (queue != null) { + var tsk = queue.Pump (x.Token); + if (tsk != null) + pending_ops.Add (tsk); + } + } + } + } catch (Exception e) { + Debug ($"got exception {e}"); + //throw; + } finally { + x.Cancel (); + } + } + } + } + + protected void Debug (string msg) + { + Console.WriteLine (msg); + } + + protected void Info (string msg) + { + Console.WriteLine (msg); + } + } +}