diff --git a/.config/dotnet/common.props b/.config/dotnet/common.props index 25f664a9..292c2ba3 100644 --- a/.config/dotnet/common.props +++ b/.config/dotnet/common.props @@ -33,8 +33,16 @@ all runtime; build; native; contentfiles; analyzers - - + + all runtime; build; native; contentfiles; analyzers diff --git a/src/editors/rider/src/main/resources/META-INF/plugin.xml b/src/editors/rider/src/main/resources/META-INF/plugin.xml index a7971d06..1ede632d 100644 --- a/src/editors/rider/src/main/resources/META-INF/plugin.xml +++ b/src/editors/rider/src/main/resources/META-INF/plugin.xml @@ -16,8 +16,7 @@ - + @@ -25,7 +24,8 @@ id="Forge Solution" anchor="left" icon="/icons/forge.svg" - factoryClass="com.forgelsp.rider.toolwindow.ForgeSolutionToolWindowFactory"/> + factoryClass="com.forgelsp.rider.toolwindow.ForgeSolutionToolWindowFactory" + /> @@ -33,23 +33,22 @@ id="Forge NuGet" anchor="bottom" icon="/icons/forge.svg" - factoryClass="com.forgelsp.rider.toolwindow.nuget.ForgeNuGetToolWindowFactory"/> + factoryClass="com.forgelsp.rider.toolwindow.nuget.ForgeNuGetToolWindowFactory" + /> - + + instance="com.forgelsp.rider.settings.ForgeSettingsConfigurable" + /> - + diff --git a/src/sharplsp/tests/fixtures/NuGetTest/NuGetTest.csproj b/src/sharplsp/tests/fixtures/NuGetTest/NuGetTest.csproj index 9d246f81..7674d18b 100644 --- a/src/sharplsp/tests/fixtures/NuGetTest/NuGetTest.csproj +++ b/src/sharplsp/tests/fixtures/NuGetTest/NuGetTest.csproj @@ -6,6 +6,5 @@ - - + diff --git a/src/sharplsp/tests/fixtures/ProfileTarget/Program.cs b/src/sharplsp/tests/fixtures/ProfileTarget/Program.cs index f890a070..7204c68c 100644 --- a/src/sharplsp/tests/fixtures/ProfileTarget/Program.cs +++ b/src/sharplsp/tests/fixtures/ProfileTarget/Program.cs @@ -6,7 +6,11 @@ using System.Text.Json; using var cts = new CancellationTokenSource(); -Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + cts.Cancel(); +}; // Self-terminate when orphaned: if the parent (the test host) dies abnormally — // e.g. nextest SIGKILLs a timed-out test — Rust-side `Drop` cleanup never runs @@ -27,7 +31,10 @@ Task.Run(() => StringBuilderAllocation(cts.Token), cts.Token), }; -try { await Task.WhenAll(tasks).ConfigureAwait(false); } +try +{ + await Task.WhenAll(tasks).ConfigureAwait(false); +} catch (OperationCanceledException) { } // Cancel `cts` as soon as this process is reparented away from its original @@ -102,7 +109,9 @@ static void StartWindowsParentDeathWatchdog(CancellationTokenSource cts) { // Wait failure means we can no longer observe the ancestor; // treat it as death so we never linger as an unwatched orphan. - WatchdogLog($"wait on ancestor {watched.Id} failed ({ex.GetType().Name}) -> cancel"); + WatchdogLog( + $"wait on ancestor {watched.Id} failed ({ex.GetType().Name}) -> cancel" + ); } cts.Cancel(); @@ -220,19 +229,22 @@ static void LockContention(CancellationToken ct) var queue = new Queue(); var locker = new object(); const int maxQueueDepth = 256; - var producer = Task.Run(() => - { - var i = 0; - while (!ct.IsCancellationRequested) + var producer = Task.Run( + () => { - lock (locker) + var i = 0; + while (!ct.IsCancellationRequested) { - if (queue.Count < maxQueueDepth) - queue.Enqueue(i++); + lock (locker) + { + if (queue.Count < maxQueueDepth) + queue.Enqueue(i++); + } + Thread.SpinWait(128); } - Thread.SpinWait(128); - } - }, ct); + }, + ct + ); while (!ct.IsCancellationRequested) { @@ -261,7 +273,8 @@ static void DeepCallStack(CancellationToken ct) static int SumCharValues(string text) { var sum = 0; - foreach (var c in text) sum += c; + foreach (var c in text) + sum += c; return sum; } @@ -272,9 +285,7 @@ static void StringBuilderAllocation(CancellationToken ct) while (!ct.IsCancellationRequested) { iteration++; - _ = iteration % 2 == 0 - ? BuildWithStringBuilder(64) - : BuildWithConcatenation(64); + _ = iteration % 2 == 0 ? BuildWithStringBuilder(64) : BuildWithConcatenation(64); } } @@ -300,7 +311,8 @@ static string BuildLargeJsonPayload(int entries) sb.Append('{'); for (var i = 0; i < entries; i++) { - if (i > 0) sb.Append(','); + if (i > 0) + sb.Append(','); sb.Append(System.FormattableString.Invariant($"\"key{i}\":\"value{i}\"")); } sb.Append('}'); @@ -318,14 +330,16 @@ internal static class NativeMethods // (SYSLIB1062), not worth enabling for one getppid (suppressed in the csproj). [System.Runtime.InteropServices.DllImport("libc")] [System.Runtime.InteropServices.DefaultDllImportSearchPaths( - System.Runtime.InteropServices.DllImportSearchPath.System32)] + System.Runtime.InteropServices.DllImportSearchPath.System32 + )] internal static extern int getppid(); // Windows has no getppid(2); the parent PID lives in // PROCESS_BASIC_INFORMATION.InheritedFromUniqueProcessId, reachable only // via NtQueryInformationProcess (info class 0 = ProcessBasicInformation). [System.Runtime.InteropServices.StructLayout( - System.Runtime.InteropServices.LayoutKind.Sequential)] + System.Runtime.InteropServices.LayoutKind.Sequential + )] private struct ProcessBasicInformation { public IntPtr ExitStatus; @@ -338,13 +352,15 @@ private struct ProcessBasicInformation [System.Runtime.InteropServices.DllImport("ntdll.dll")] [System.Runtime.InteropServices.DefaultDllImportSearchPaths( - System.Runtime.InteropServices.DllImportSearchPath.System32)] + System.Runtime.InteropServices.DllImportSearchPath.System32 + )] private static extern int NtQueryInformationProcess( IntPtr processHandle, int processInformationClass, ref ProcessBasicInformation processInformation, int processInformationLength, - out int returnLength); + out int returnLength + ); /// Creator (parent) PID of the process behind /// on Windows, or -1 when it cannot be determined. @@ -356,7 +372,8 @@ internal static int GetParentPid(IntPtr processHandle) 0, ref info, System.Runtime.InteropServices.Marshal.SizeOf(), - out _); + out _ + ); return status == 0 ? unchecked((int)info.InheritedFromUniqueProcessId.ToInt64()) : -1; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs index dfc53311..60752679 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs @@ -312,6 +312,37 @@ public async Task ResolveCompletion_after_completion_returns_resolve_result() Assert.Empty(guarded.AdditionalEdits); } + [Fact] + public async Task ResolveCompletion_with_empty_span_skips_primary_edit_in_additional_edits() + { + // GitHub double-insertion bug: triggering completion immediately after a dot + // produces a length-0 completion span. ResolveCompletion must use IntersectsWith + // to correctly skip the primary edit so it doesn't get double-applied. + using var manager = await OpenAsync(); + + // Add a dot to trigger completion on an empty span. + var newSource = Source.Replace("Total = result;", "Total = result.;"); + await manager.UpdateDocumentTextAsync(_sourcePath, newSource); + + // Position: line 19, char 23 (immediately after `result.`) + var completions = await manager.GetCompletionsAsync(_sourcePath, 19, 23); + var items = Unwrap(completions); + + // Find a valid completion like "ToString" + var toString = items.Find(item => item.Label == "ToString"); + Assert.NotNull(toString); + + var resolved = await manager.ResolveCompletionAsync( + toString!.Index, + CancellationToken.None + ); + Assert.NotNull(resolved); + + // The primary edit (inserting "ToString") MUST be skipped. AdditionalEdits + // should be empty (or at least not contain "ToString" at the cursor). + Assert.DoesNotContain(resolved.AdditionalEdits, edit => edit.NewText == "ToString"); + } + [Fact] public async Task CodeLenses_report_reference_counts_for_members() { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs index c0aa603b..720c9c28 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerSingleFileTests.cs @@ -392,7 +392,11 @@ public async Task FileBasedApp_closure_file_count_is_bounded() } var app = Write("Many.cs", "#:include many/*.cs\nConsole.WriteLine(1);\n"); - var closure = await DocumentClosure.ExpandFileBasedAsync(app, CancellationToken.None); + var closure = await DocumentClosure.ExpandFileBasedAsync( + app, + rootText: null, + CancellationToken.None + ); Assert.Equal(64, closure.Files.Count); Assert.Contains( @@ -415,7 +419,11 @@ public async Task FileBasedApp_include_depth_is_bounded() } var app = Write("Chain.cs", "#:include chain0.cs\nConsole.WriteLine(1);\n"); - var closure = await DocumentClosure.ExpandFileBasedAsync(app, CancellationToken.None); + var closure = await DocumentClosure.ExpandFileBasedAsync( + app, + rootText: null, + CancellationToken.None + ); Assert.Contains( closure.Issues, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs index aaf38f8a..b0a8f252 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentClosure.cs @@ -6,8 +6,14 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// One source file in a file-based app or script closure. internal sealed record ClosureFile(string Path, string Text, bool IsRoot); +internal sealed record PackageRef(string Name, string Version); + /// Result of expanding a closure: the files, plus any non-fatal problems. -internal sealed record Closure(IReadOnlyList Files, IReadOnlyList Issues); +internal sealed record Closure( + IReadOnlyList Files, + IReadOnlyList Packages, + IReadOnlyList Issues +); /// /// Expands the compilation closure of a project-less document. Implements [SCRIPT-CLOSURE]. @@ -24,9 +30,13 @@ internal static class DocumentClosure private const int MaxDepth = 8; /// Expand a C# file-based app closure: root file plus transitive #:include. - public static Task ExpandFileBasedAsync(string rootPath, CancellationToken ct) + public static Task ExpandFileBasedAsync( + string rootPath, + string? rootText = null, + CancellationToken ct = default + ) { - return ExpandAsync(rootPath, IncludedPaths, ct); + return ExpandAsync(rootPath, rootText, IncludedPaths, ct); } /// @@ -34,13 +44,17 @@ public static Task ExpandFileBasedAsync(string rootPath, CancellationTo /// compilation's SourceReferenceResolver; adding the loaded files as documents too /// would compile them twice. Implements [SCRIPT-CSX-RESOLVERS]. /// - public static Task ExpandScriptAsync(string rootPath, CancellationToken ct) + public static Task ExpandScriptAsync( + string rootPath, + string? rootText = null, + CancellationToken ct = default + ) { - return ExpandAsync(rootPath, NoChildren, ct); + return ExpandAsync(rootPath, rootText, NoChildren, ct); } private static IEnumerable NoChildren( - string text, + IReadOnlyList directives, string filePath, ExpansionState state ) @@ -50,23 +64,26 @@ ExpansionState state private static async Task ExpandAsync( string rootPath, + string? rootText, ChildResolver children, CancellationToken ct ) { var state = new ExpansionState(children); - await VisitAsync(rootPath, isRoot: true, depth: 0, state, ct).ConfigureAwait(false); - return new Closure(state.Files, state.Issues); + await VisitAsync(rootPath, rootText, isRoot: true, depth: 0, state, ct) + .ConfigureAwait(false); + return new Closure(state.Files, state.Packages, state.Issues); } private delegate IEnumerable ChildResolver( - string text, + IReadOnlyList directives, string filePath, ExpansionState state ); private static async Task VisitAsync( string path, + string? textOverride, bool isRoot, int depth, ExpansionState state, @@ -80,7 +97,11 @@ CancellationToken ct return; } - var read = await ReadAsync(full, ct).ConfigureAwait(false); + var read = + isRoot && textOverride != null + ? textOverride + : await ReadAsync(full, ct).ConfigureAwait(false); + if (read is null) { state.Issues.Add($"Could not read '{full}'; it was excluded from the closure."); @@ -88,9 +109,32 @@ CancellationToken ct } state.Files.Add(new ClosureFile(full, read, isRoot)); - foreach (var child in state.Children(read, full, state)) + + var tree = CSharpSyntaxTree.ParseText( + read, + FileBasedParseOptions, + path: full, + cancellationToken: ct + ); + var root = await tree.GetRootAsync(ct).ConfigureAwait(false); + var directives = FileLevelDirectives.Parse(root); + + foreach (var directive in directives) + { + if ( + directive.Kind == FileDirectiveKind.Package + && !string.IsNullOrEmpty(directive.Name) + && !string.IsNullOrEmpty(directive.Value) + ) + { + state.Packages.Add(new PackageRef(directive.Name, directive.Value)); + } + } + + foreach (var child in state.Children(directives, full, state)) { - await VisitAsync(child, isRoot: false, depth + 1, state, ct).ConfigureAwait(false); + await VisitAsync(child, textOverride: null, isRoot: false, depth + 1, state, ct) + .ConfigureAwait(false); } } @@ -117,13 +161,11 @@ private static void RecordBound(string full, int depth, ExpansionState state) ).WithFeatures([new KeyValuePair("FileBasedProgram", "true")]); private static IEnumerable IncludedPaths( - string text, + IReadOnlyList directives, string filePath, ExpansionState state ) { - var tree = CSharpSyntaxTree.ParseText(text, FileBasedParseOptions, path: filePath); - var directives = FileLevelDirectives.Parse(tree.GetRoot()); var baseDir = Path.GetDirectoryName(filePath) ?? "."; return directives .Where(d => d.Kind == FileDirectiveKind.Include) @@ -191,6 +233,7 @@ private sealed class ExpansionState(ChildResolver children) public ChildResolver Children { get; } = children; public HashSet Visited { get; } = new(StringComparer.OrdinalIgnoreCase); public List Files { get; } = []; + public List Packages { get; } = []; public List Issues { get; } = []; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs index 6394d1a1..8c103c66 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs @@ -186,7 +186,7 @@ _lastCompletionList is null foreach (var textChange in change.TextChanges) { - if (textChange.Span.OverlapsWith(completionSpan)) + if (textChange.Span.IntersectsWith(completionSpan)) { continue; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs index 7e6be6f9..fbc8909c 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SingleFile.cs @@ -94,8 +94,8 @@ CancellationToken ct ) { return kind == ProjectlessKind.Script - ? DocumentClosure.ExpandScriptAsync(path, ct) - : DocumentClosure.ExpandFileBasedAsync(path, ct); + ? DocumentClosure.ExpandScriptAsync(path, rootText: null, ct) + : DocumentClosure.ExpandFileBasedAsync(path, rootText: null, ct); } private async Task LoadClosureAsync( @@ -107,10 +107,25 @@ CancellationToken ct { _adhocWorkspace ??= new AdhocWorkspace(); - var project = _adhocWorkspace.AddProject(BuildProjectInfo(kind, rootPath)); + var packageReferences = await ResolvePackagesAsync(closure.Packages, ct) + .ConfigureAwait(false); + var project = _adhocWorkspace.AddProject( + BuildProjectInfo(kind, rootPath, packageReferences) + ); + DocumentId? rootDocumentId = null; foreach (var file in closure.Files) { - _ = _adhocWorkspace.AddDocument(BuildDocumentInfo(project.Id, file, kind)); + var docInfo = BuildDocumentInfo(project.Id, file, kind); + if (file.IsRoot) + { + rootDocumentId = docInfo.Id; + } + _ = _adhocWorkspace.AddDocument(docInfo); + } + + if (rootDocumentId != null) + { + _documentPackages[rootDocumentId] = closure.Packages; } if (kind == ProjectlessKind.FileBasedApp) @@ -133,7 +148,158 @@ CancellationToken ct return new VoidResult.Ok(Unit.Value); } - private static ProjectInfo BuildProjectInfo(ProjectlessKind kind, string rootPath) + internal async Task UpdateProjectlessClosureAsync( + Document document, + string newText, + CancellationToken ct + ) + { + if (_solution is null) + { + return VoidResult.Failure("No active solution."); + } + + var kind = Classify(document.FilePath!); + var closure = + kind == ProjectlessKind.Script + ? await DocumentClosure + .ExpandScriptAsync(document.FilePath!, newText, ct) + .ConfigureAwait(false) + : await DocumentClosure + .ExpandFileBasedAsync(document.FilePath!, newText, ct) + .ConfigureAwait(false); + + var currentProject = _solution.GetProject(document.Project.Id); + if (currentProject == null) + { + return VoidResult.Failure("Project not found."); + } + + var currentDocIds = currentProject.Documents.ToDictionary(d => d.FilePath!, d => d.Id); + var closurePaths = closure.Files.Select(f => f.Path).ToHashSet(); + + var nextSolution = _solution; + + // Add new documents + foreach (var file in closure.Files) + { + if (!currentDocIds.ContainsKey(file.Path)) + { + var docInfo = BuildDocumentInfo(currentProject.Id, file, kind); + nextSolution = nextSolution.AddDocument(docInfo); + } + } + + // Remove orphaned documents (except the root document) + foreach (var (path, docId) in currentDocIds) + { + if (path.EndsWith(GlobalUsingsFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!closurePaths.Contains(path) && path != document.FilePath) + { + nextSolution = nextSolution.RemoveDocument(docId); + } + } + + // Handle package references + if ( + _documentPackages.TryGetValue(document.Id, out var oldPackages) + && !closure.Packages.SequenceEqual(oldPackages) + ) + { + var newReferences = await ResolvePackagesAsync(closure.Packages, ct) + .ConfigureAwait(false); + var updatedProject = nextSolution + .GetProject(currentProject.Id)! + .WithMetadataReferences( + Basic.Reference.Assemblies.Net100.References.All.Concat(newReferences) + ); + nextSolution = updatedProject.Solution; + _documentPackages[document.Id] = closure.Packages; + } + + _solution = nextSolution; + return new VoidResult.Ok(Unit.Value); + } + + private static async Task> ResolvePackagesAsync( + IReadOnlyList packages, + CancellationToken ct + ) + { + if (packages.Count == 0) + { + return []; + } + + var tempDir = Path.Combine( + Path.GetTempPath(), + "SharpLsp_Packages_" + Guid.NewGuid().ToString("N") + ); + _ = Directory.CreateDirectory(tempDir); + try + { + var projPath = Path.Combine(tempDir, "restore.csproj"); + var packageItems = string.Join( + "\n", + packages.Select(p => + $"" + ) + ); + var xml = + $@" + + net10.0 + + + {packageItems} + +"; + await File.WriteAllTextAsync(projPath, xml, ct).ConfigureAwait(false); + + var psi = new System.Diagnostics.ProcessStartInfo("dotnet", "restore --verbosity quiet") + { + WorkingDirectory = tempDir, + CreateNoWindow = true, + UseShellExecute = false, + }; + using var process = System.Diagnostics.Process.Start(psi); + if (process != null) + { + await process.WaitForExitAsync(ct).ConfigureAwait(false); + } + + using var workspace = Microsoft.CodeAnalysis.MSBuild.MSBuildWorkspace.Create( + new Dictionary + { + ["DesignTimeBuild"] = "true", + ["BuildingInsideVisualStudio"] = "true", + ["SkipCompilerExecution"] = "true", + } + ); + var project = await workspace + .OpenProjectAsync(projPath, cancellationToken: ct) + .ConfigureAwait(false); + return project.MetadataReferences.OfType(); + } + finally + { + try + { + Directory.Delete(tempDir, true); + } + catch { } + } + } + + private static ProjectInfo BuildProjectInfo( + ProjectlessKind kind, + string rootPath, + IEnumerable extraReferences + ) { var name = Path.GetFileNameWithoutExtension(rootPath); var isScript = kind == ProjectlessKind.Script; @@ -146,9 +312,11 @@ private static ProjectInfo BuildProjectInfo(ProjectlessKind kind, string rootPat filePath: rootPath, compilationOptions: BuildCompilationOptions(isScript, rootPath), parseOptions: BuildParseOptions(isScript), - // Tier 2 reference resolution: in-memory BCL only. `#:package` symbols do not bind - // until the synthesized-project path lands. [SCRIPT-FILEBASED-REFERENCES-FALLBACK] - metadataReferences: Basic.Reference.Assemblies.Net100.References.All + // Tier 2 reference resolution: in-memory BCL only. `#:package` symbols bind + // via MSBuildWorkspace synthetic evaluation fallback. [SCRIPT-FILEBASED-REFERENCES-FALLBACK] + metadataReferences: Basic.Reference.Assemblies.Net100.References.All.Concat( + extraReferences + ) ); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs index 4e31e9ca..5adce3db 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs @@ -76,6 +76,11 @@ public void Dispose() StringComparer.OrdinalIgnoreCase ); + private readonly System.Collections.Generic.Dictionary< + Microsoft.CodeAnalysis.DocumentId, + System.Collections.Generic.IReadOnlyList + > _documentPackages = new(); + public bool IsLoaded => _solution is not null; /// Open a solution or project file via MSBuildWorkspace. @@ -147,6 +152,18 @@ public async Task UpdateDocumentTextAsync( } _solution = _solution.WithDocumentText(document.Id, SourceText.From(newText)); + + // Auto-update the closure and packages if they changed during a live edit + if (document.Project.Solution.Workspace is AdhocWorkspace) + { + var updateResult = await UpdateProjectlessClosureAsync(document, newText, ct) + .ConfigureAwait(false); + if (updateResult.IsError) + { + return updateResult; + } + } + return new VoidResult.Ok(Unit.Value); } finally