using NodeDev.Core.Class; using NodeDev.Core.Connections; using NodeDev.Core.Debugger; using NodeDev.Core.Migrations; using NodeDev.Core.Nodes; using NodeDev.Core.Types; using System.Reactive.Linq; using System.Reactive.Subjects; using System.Reflection; using System.Reflection.Emit; using System.Text.Json; using System.Text.Json.Nodes; [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NodeDev.Tests")] namespace NodeDev.Core; public class Project { /// /// Maximum time to wait for process output streams to be fully consumed after process exits. /// private static readonly TimeSpan OutputStreamTimeout = TimeSpan.FromSeconds(5); internal record class SerializedProject(Guid Id, string NodeDevVersion, List Classes, ProjectSettings Settings); internal readonly Guid Id; internal readonly string NodeDevVersion; public static string CurrentNodeDevVersion => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? throw new Exception("Unable to get current NodeDev version"); private readonly List _Classes = []; public IReadOnlyList Classes => _Classes; private readonly Dictionary NodeClassTypes = []; public readonly TypeFactory TypeFactory; public ProjectSettings Settings { get; private set; } = ProjectSettings.Default; public NodeClassTypeCreator? NodeClassTypeCreator { get; private set; } public GraphExecutor? GraphExecutor { get; set; } internal Subject<(Graph Graph, bool RequireUIRefresh)> GraphChangedSubject { get; } = new(); internal Subject<(GraphExecutor Executor, Node Node, Connection Exec)> GraphNodeExecutingSubject { get; } = new(); internal Subject<(GraphExecutor Executor, Node Node, Connection Exec)> GraphNodeExecutedSubject { get; } = new(); internal Subject GraphExecutionChangedSubject { get; } = new(); internal Subject ConsoleOutputSubject { get; } = new(); internal Subject DebugCallbackSubject { get; } = new(); internal Subject HardDebugStateChangedSubject { get; } = new(); public IObservable<(Graph Graph, bool RequireUIRefresh)> GraphChanged => GraphChangedSubject.AsObservable(); public IObservable<(GraphExecutor Executor, Node Node, Connection Exec)> GraphNodeExecuting => GraphNodeExecutingSubject.AsObservable(); public IObservable<(GraphExecutor Executor, Node Node, Connection Exec)> GraphNodeExecuted => GraphNodeExecutedSubject.AsObservable(); public IObservable GraphExecutionChanged => GraphExecutionChangedSubject.AsObservable(); public IObservable ConsoleOutput => ConsoleOutputSubject.AsObservable(); public IObservable DebugCallbacks => DebugCallbackSubject.AsObservable(); public IObservable HardDebugStateChanged => HardDebugStateChangedSubject.AsObservable(); public bool IsLiveDebuggingEnabled { get; private set; } private DebugSessionEngine? _debugEngine; private System.Diagnostics.Process? _debuggedProcess; /// /// Gets whether the project is currently being debugged with hard debugging (ICorDebug). /// public bool IsHardDebugging => _debugEngine?.IsAttached ?? false; /// /// Gets the process ID of the currently debugged process, or null if not debugging. /// public int? DebuggedProcessId => _debuggedProcess?.Id; public Project(Guid id, string? nodeDevVersion = null) { Id = id; NodeDevVersion = nodeDevVersion ?? CurrentNodeDevVersion; TypeFactory = new TypeFactory(this); } public IEnumerable GetNodes() where T : Node { return Classes.SelectMany(x => x.Methods).SelectMany(x => x.Graph.Nodes.Values).OfType(); } #region CreateNewDefaultProject public static Project CreateNewDefaultProject() { return CreateNewDefaultProject(out _); } public static Project CreateNewDefaultProject(out NodeClassMethod main) { var project = new Project(Guid.NewGuid()); var programClass = new NodeClass("Program", "NewProject", project); project.AddClass(programClass); // Create the main method and add it to the project main = new NodeClassMethod(programClass, "Main", project.TypeFactory.Get(), true); programClass.AddMethod(main, createEntryAndReturn: true); // Now that the method is created with its entry and return nodes, we can set the default return value of 0 main.ReturnNodes.First().Inputs[1].UpdateTextboxText("0"); return project; } #endregion #region AddClass public void AddClass(NodeClass nodeClass) { _Classes.Add(nodeClass); } #endregion #region Build public string Build(BuildOptions buildOptions) { // Use unique name based on project ID to avoid conflicts when running tests in parallel // Using "N" format avoids hyphens and is more efficient than Replace var name = $"project_{Id:N}"; // Use Roslyn compilation var compiler = new RoslynNodeClassCompiler(this, buildOptions); var result = compiler.Compile(); // Check if this is an executable (has a Program.Main method) bool isExecutable = HasMainMethod(); Directory.CreateDirectory(buildOptions.OutputPath); var fileExtension = isExecutable ? ".exe" : ".dll"; var filePath = Path.Combine(buildOptions.OutputPath, $"{name}{fileExtension}"); var pdbPath = Path.Combine(buildOptions.OutputPath, $"{name}.pdb"); // Write the PE and PDB to files File.WriteAllBytes(filePath, result.PEBytes); File.WriteAllBytes(pdbPath, result.PDBBytes); if (isExecutable) { // Create runtime config for executables File.WriteAllText(Path.Combine(buildOptions.OutputPath, $"{name}.runtimeconfig.json"), @$"{{ ""runtimeOptions"": {{ ""tfm"": ""net{Environment.Version.Major}.{Environment.Version.Minor}"", ""framework"": {{ ""name"": ""Microsoft.NETCore.App"", ""version"": ""{GetNetCoreVersion()}"" }} }} }}"); } return filePath; } /// /// Checks if the project has a static Main method in a Program class (indicating an executable) /// internal bool HasMainMethod() { var program = Classes.FirstOrDefault(x => x.Name == "Program"); var mainMethod = program?.Methods.FirstOrDefault(x => x.Name == "Main" && x.IsStatic); return program != null && mainMethod != null; } private static string GetNetCoreVersion() { var assembly = typeof(System.Runtime.GCSettings).GetTypeInfo().Assembly; var assemblyPath = assembly.Location.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); int netCoreAppIndex = Array.IndexOf(assemblyPath, "Microsoft.NETCore.App"); if (netCoreAppIndex > 0 && netCoreAppIndex < assemblyPath.Length - 2) return assemblyPath[netCoreAppIndex + 1]; return ""; } public AssemblyBuilder BuildAndGetAssembly(BuildOptions buildOptions) { CreateNodeClassTypeCreator(buildOptions); if (NodeClassTypeCreator == null) throw new NullReferenceException($"NodeClassTypeCreator should not be null after {nameof(CreateNodeClassTypeCreator)} was called, this shouldn't happen"); NodeClassTypeCreator.CreateProjectClassesAndAssembly(); var assembly = NodeClassTypeCreator.Assembly; if (assembly == null) throw new NullReferenceException("NodeClassTypeCreator.Assembly null after project was compiled, this shouldn't happen"); return assembly; } /// /// Builds the project using Roslyn compilation (new approach) /// public Assembly BuildWithRoslyn(BuildOptions buildOptions) { var compiler = new RoslynNodeClassCompiler(this, buildOptions); var result = compiler.Compile(); return result.Assembly; } #endregion #region Run /// /// Locates the ScriptRunner executable in the build output directory. /// ScriptRunner is a console application that serves as the target process for debugging. /// private static string FindScriptRunnerExecutable() { // Get the directory where NodeDev.Core assembly is located string coreAssemblyLocation = Assembly.GetExecutingAssembly().Location; string coreDirectory = Path.GetDirectoryName(coreAssemblyLocation) ?? throw new Exception("Unable to determine NodeDev.Core assembly directory"); // ScriptRunner should be in the same build output directory string scriptRunnerDll = Path.Combine(coreDirectory, "NodeDev.ScriptRunner.dll"); if (File.Exists(scriptRunnerDll)) { return scriptRunnerDll; } // If not found in the same directory, try looking in sibling directories (for development scenarios) string? buildOutputRoot = Path.GetDirectoryName(coreDirectory); if (buildOutputRoot != null) { string scriptRunnerPath = Path.Combine(buildOutputRoot, "NodeDev.ScriptRunner", "NodeDev.ScriptRunner.dll"); if (File.Exists(scriptRunnerPath)) { return scriptRunnerPath; } } throw new FileNotFoundException("ScriptRunner executable not found. Please ensure NodeDev.ScriptRunner is built and available."); } /// /// Gets the path to the ScriptRunner executable. /// This is useful for debugging infrastructure that needs to locate the runner. /// /// The full path to NodeDev.ScriptRunner.dll public string GetScriptRunnerPath() { return FindScriptRunnerExecutable(); } public object? Run(BuildOptions options, params object?[] inputs) { try { var assemblyPath = Build(options); // Find the ScriptRunner executable string scriptRunnerPath = FindScriptRunnerExecutable(); // Convert to absolute path to avoid confusion with working directory string absoluteAssemblyPath = Path.GetFullPath(assemblyPath); // Build arguments: ScriptRunner.dll path-to-user-dll [user-args...] var userArgsString = string.Join(" ", inputs.Select(x => '"' + (x?.ToString() ?? "") + '"')); var arguments = $"\"{scriptRunnerPath}\" \"{absoluteAssemblyPath}\""; if (!string.IsNullOrEmpty(userArgsString)) { arguments += $" {userArgsString}"; } var processStartInfo = new System.Diagnostics.ProcessStartInfo() { FileName = "dotnet", Arguments = arguments, WorkingDirectory = Path.GetDirectoryName(absoluteAssemblyPath), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; var process = System.Diagnostics.Process.Start(processStartInfo) ?? throw new Exception("Unable to start process"); // Use ManualResetEvents to track when async output handlers complete // Using 'using' ensures proper disposal of unmanaged resources using var outputComplete = new System.Threading.ManualResetEvent(false); using var errorComplete = new System.Threading.ManualResetEvent(false); // Notify that execution has started GraphExecutionChangedSubject.OnNext(true); // Capture output and error streams // The null event signals end of stream process.OutputDataReceived += (sender, e) => { if (e.Data != null) { ConsoleOutputSubject.OnNext(e.Data + Environment.NewLine); } else { // End of stream outputComplete.Set(); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { ConsoleOutputSubject.OnNext(e.Data + Environment.NewLine); } else { // End of stream errorComplete.Set(); } }; process.BeginOutputReadLine(); process.BeginErrorReadLine(); process.WaitForExit(); // Wait for output streams to be fully consumed (with timeout) outputComplete.WaitOne(OutputStreamTimeout); errorComplete.WaitOne(OutputStreamTimeout); // Notify that execution has completed GraphExecutionChangedSubject.OnNext(false); return process.ExitCode; } catch (Exception) { // Notify that execution has completed even on error GraphExecutionChangedSubject.OnNext(false); return null; } finally { NodeClassTypeCreator = null; GC.Collect(); } } /// /// Runs the project with hard debugging (ICorDebug) attached. /// /// Build options to use. /// Input parameters for the main method. /// The exit code of the process, or null if execution failed. public object? RunWithDebug(BuildOptions options, params object?[] inputs) { try { var assemblyPath = Build(options); // Find the ScriptRunner executable string scriptRunnerPath = FindScriptRunnerExecutable(); // Convert to absolute path to avoid confusion with working directory string absoluteAssemblyPath = Path.GetFullPath(assemblyPath); // Build arguments: ScriptRunner.dll --wait-for-debugger path-to-user-dll [user-args...] var userArgsString = string.Join(" ", inputs.Select(x => '"' + (x?.ToString() ?? "") + '"')); var arguments = $"\"{scriptRunnerPath}\" --wait-for-debugger \"{absoluteAssemblyPath}\""; if (!string.IsNullOrEmpty(userArgsString)) { arguments += $" {userArgsString}"; } var processStartInfo = new System.Diagnostics.ProcessStartInfo() { FileName = "dotnet", Arguments = arguments, WorkingDirectory = Path.GetDirectoryName(absoluteAssemblyPath), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; var process = System.Diagnostics.Process.Start(processStartInfo) ?? throw new Exception("Unable to start process"); _debuggedProcess = process; // Use ManualResetEvents to track when async output handlers complete using var outputComplete = new System.Threading.ManualResetEvent(false); using var errorComplete = new System.Threading.ManualResetEvent(false); // Notify that execution has started GraphExecutionChangedSubject.OnNext(true); // Capture output and error streams process.OutputDataReceived += (sender, e) => { if (e.Data != null) { ConsoleOutputSubject.OnNext(e.Data + Environment.NewLine); } else { outputComplete.Set(); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { ConsoleOutputSubject.OnNext(e.Data + Environment.NewLine); } else { errorComplete.Set(); } }; process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Initialize debug engine var shimPath = DbgShimResolver.TryResolve(); if (shimPath == null) { // Kill the process since we can't attach debugger try { process.Kill(); } catch { } throw new InvalidOperationException( "DbgShim library not found. Debugging features are not available on this system.\n\n" + "The DbgShim library is required for debugging and should be installed automatically via the Microsoft.Diagnostics.DbgShim NuGet package.\n" + "Please ensure NuGet packages are properly restored."); } _debugEngine = new DebugSessionEngine(shimPath); try { _debugEngine.Initialize(); } catch (Exception ex) { // Kill the process since we can't attach debugger try { process.Kill(); } catch { } throw new InvalidOperationException($"Failed to initialize debug engine: {ex.Message}", ex); } // Subscribe to debug callbacks _debugEngine.DebugCallback += (sender, args) => { DebugCallbackSubject.OnNext(args); }; // Use the process ID directly int targetPid = process.Id; // Wait for CLR to load with polling const int maxAttempts = 20; const int pollIntervalMs = 200; string[]? clrs = null; for (int attempt = 0; attempt < maxAttempts; attempt++) { System.Threading.Thread.Sleep(pollIntervalMs); try { clrs = _debugEngine.EnumerateCLRs(targetPid); if (clrs.Length > 0) break; } catch (DebugEngineException) { // CLR not ready yet, keep trying } catch (Exception) { // Process may have exited if (process.HasExited) break; } } if (clrs == null || clrs.Length == 0) { // Kill the process since we can't attach debugger try { process.Kill(); } catch { } string errorMsg = process.HasExited ? $"Target process exited unexpectedly (exit code: {process.ExitCode}) before CLR could be enumerated.\n\n" + "The process may have crashed or completed too quickly for the debugger to attach." : "Could not enumerate CLRs in the target process after multiple attempts.\n\n" + "The CLR runtime may not have loaded, or the process may not be a valid .NET application."; throw new InvalidOperationException(errorMsg); } // Attach debugger try { var corDebug = _debugEngine.AttachToProcess(targetPid); var debugProcess = _debugEngine.SetupDebugging(corDebug, targetPid); // Notify that we're now debugging HardDebugStateChangedSubject.OnNext(true); ConsoleOutputSubject.OnNext("Debugger attached successfully." + Environment.NewLine); } catch (Exception ex) when (ex is not InvalidOperationException) { // Kill the process since we can't attach debugger try { process.Kill(); } catch { } throw new InvalidOperationException($"Failed to attach debugger to process: {ex.Message}", ex); } // Wait for the process to complete process.WaitForExit(); // Wait for output streams to be fully consumed outputComplete.WaitOne(OutputStreamTimeout); errorComplete.WaitOne(OutputStreamTimeout); // Notify that debugging has stopped HardDebugStateChangedSubject.OnNext(false); GraphExecutionChangedSubject.OnNext(false); return process.ExitCode; } catch (Exception ex) { ConsoleOutputSubject.OnNext($"Error during debug execution: {ex.Message}" + Environment.NewLine); HardDebugStateChangedSubject.OnNext(false); GraphExecutionChangedSubject.OnNext(false); return null; } finally { _debugEngine?.Dispose(); _debugEngine = null; _debuggedProcess = null; NodeClassTypeCreator = null; GC.Collect(); } } /// /// Stops the current debugging session by detaching the debugger and terminating the process. /// public void StopDebugging() { if (!IsHardDebugging) return; try { // Detach debugger first _debugEngine?.Detach(); // Try to kill the process if it's still running if (_debuggedProcess != null && !_debuggedProcess.HasExited) { try { _debuggedProcess.Kill(); _debuggedProcess.WaitForExit(5000); // Wait up to 5 seconds } catch { // Ignore errors if process is already gone } } // Notify that debugging has stopped HardDebugStateChangedSubject.OnNext(false); GraphExecutionChangedSubject.OnNext(false); ConsoleOutputSubject.OnNext("Debugging stopped by user." + Environment.NewLine); } finally { _debugEngine?.Dispose(); _debugEngine = null; _debuggedProcess = null; } } #endregion #region GetCreatedClassType / GetNodeClassType public NodeClassTypeCreator CreateNodeClassTypeCreator(BuildOptions options) { return NodeClassTypeCreator = new(this, options); } public Type GetCreatedClassType(NodeClass nodeClass) { if (NodeClassTypeCreator == null) throw new Exception("NodeClassTypeCreator is null, is the project currently being run?"); if (NodeClassTypeCreator.GeneratedTypes.TryGetValue(GetNodeClassType(nodeClass), out var generatedType)) return generatedType.Type; throw new Exception("Unable to get generated node class for provided class: " + nodeClass.Name); } public NodeClassType GetNodeClassType(NodeClass nodeClass, TypeBase[]? generics = null) { if (!NodeClassTypes.ContainsKey(nodeClass)) return NodeClassTypes[nodeClass] = new(nodeClass, generics ?? Array.Empty()); return NodeClassTypes[nodeClass]; } #endregion #region Serialize / Deserialize public string Serialize() { var serializedProject = new SerializedProject(Id, NodeDevVersion, Classes.Select(x => x.Serialize()).ToList(), Settings); return JsonSerializer.Serialize(serializedProject, new JsonSerializerOptions() { WriteIndented = true }); } public static Project Deserialize(string serialized) { var document = JsonNode.Parse(serialized)?.AsObject() ?? throw new Exception("Unable to deserialize project"); var migrations = GetMigrationsRequired(document); // Run the migrations that needs to change the literal JSON content before deserialization foreach (var migration in migrations) migration.PerformMigrationBeforeDeserialization(document); var serializedProject = document.Deserialize() ?? throw new Exception("Unable to deserialize project"); var project = new Project(serializedProject.Id == default ? Guid.NewGuid() : serializedProject.Id); var nodeClasses = new Dictionary(); foreach (var nodeClassSerializedObj in serializedProject.Classes) { var nodeClass = NodeClass.Deserialize(nodeClassSerializedObj, project); project._Classes.Add(nodeClass); nodeClasses[nodeClass] = nodeClassSerializedObj; } // Run the migrations that need to alter the project now that it's deserialized as well as the classes foreach (var migration in migrations) migration.PerformMigrationAfterClassesDeserialization(project); // will deserialize methods and properties but not any graphs foreach (var nodeClass in nodeClasses) nodeClass.Key.Deserialize_Step2(nodeClass.Value); // deserialize graphs foreach (var nodeClass in nodeClasses) nodeClass.Key.Deserialize_Step3(); return project; } #endregion #region Version Migrations private static List GetMigrationsRequired(JsonObject projectJson) { if (string.IsNullOrEmpty(projectJson["NodeDevVersion"]?.ToString()) || !NuGet.Versioning.NuGetVersion.TryParse(projectJson["NodeDevVersion"]?.ToString(), out var current)) { current = NuGet.Versioning.NuGetVersion.Parse("1.0.0"); projectJson["NodeDevVersion"] = "1.0.0"; } return System.Reflection.Assembly.GetExecutingAssembly() .GetTypes() .Where(x => x.IsSubclassOf(typeof(MigrationBase))) .Select(x => (MigrationBase)Activator.CreateInstance(x)!) .Select(x => (Version: NuGet.Versioning.NuGetVersion.Parse(x.Version), Migration: x)) .Where(x => x.Version > current) .OrderBy(x => x.Version) .Select(x => x.Migration) .ToList(); } #endregion #region Debugging public void StopLiveDebugging() { IsLiveDebuggingEnabled = false; // TODO Search through the executors tree and free up all unused executors } public void StartLiveDebugging() { IsLiveDebuggingEnabled = true; } #endregion }