From 6427a189982cb226b3fd79bb7d3c113e37fe62d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Wed, 12 Jun 2024 09:16:52 +0300 Subject: [PATCH 1/3] .github/workflows: use ubuntu-24.04 --- .github/workflows/build.yaml | 2 +- .github/workflows/test.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index cecc46e8..5771fb8a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,7 +8,7 @@ jobs: build: strategy: matrix: - os: [ubuntu-22.04] + os: [ubuntu-24.04] dotnet: [8.0.300] runs-on: ${{ matrix.os }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 90f33a33..fcf02fbc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,7 +9,7 @@ jobs: build: strategy: matrix: - os: [windows-latest, ubuntu-22.04] + os: [windows-latest, ubuntu-24.04] dotnet: [8.0.300] fail-fast: false From c05f098053cb025b2ee3d13f0f05b4666606683d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Wed, 12 Jun 2024 09:17:39 +0300 Subject: [PATCH 2/3] Reduce TimerTick initial period to 100ms (from 1 sec) --- src/CSharpLanguageServer/Lsp/Server.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CSharpLanguageServer/Lsp/Server.fs b/src/CSharpLanguageServer/Lsp/Server.fs index ead5fdd3..9f1e751d 100644 --- a/src/CSharpLanguageServer/Lsp/Server.fs +++ b/src/CSharpLanguageServer/Lsp/Server.fs @@ -52,7 +52,7 @@ type CSharpLspServer( System.Threading.TimerCallback( fun _ -> do diagnostics.Post(ProcessPendingDiagnostics) do stateActor.Post(PeriodicTimerTick)), - null, dueTime=1000, period=250)) + null, dueTime=100, period=250)) let mutable workspaceFolders: WorkspaceFolder list = [] From 5cdc8220c35dfc232d33672fe7af661544563d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Wed, 12 Jun 2024 09:19:03 +0300 Subject: [PATCH 3/3] Add initial (crude) version of integration tests --- src/CSharpLanguageServer/RoslynHelpers.fs | 2 +- .../CSharpLanguageServer.Tests.fsproj | 2 + .../DocumentationTests.fs | 2 + .../InitializationTests.fs | 32 ++ tests/CSharpLanguageServer.Tests/Tooling.fs | 456 ++++++++++++++++++ 5 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 tests/CSharpLanguageServer.Tests/InitializationTests.fs create mode 100644 tests/CSharpLanguageServer.Tests/Tooling.fs diff --git a/src/CSharpLanguageServer/RoslynHelpers.fs b/src/CSharpLanguageServer/RoslynHelpers.fs index 0706a85d..475a6218 100644 --- a/src/CSharpLanguageServer/RoslynHelpers.fs +++ b/src/CSharpLanguageServer/RoslynHelpers.fs @@ -414,7 +414,7 @@ let tryLoadSolutionFromProjectFiles >> Log.addContext "message" (diag.ToString()) ) - do! progress.End (sprintf "OK, %d project files loaded" projs.Length) + do! progress.End (sprintf "OK, %d project file(s) loaded" projs.Length) //workspace <- Some(msbuildWorkspace :> Workspace) return Some msbuildWorkspace.CurrentSolution diff --git a/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj b/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj index 6cd31d19..f3df0602 100644 --- a/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj +++ b/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj @@ -7,7 +7,9 @@ + + diff --git a/tests/CSharpLanguageServer.Tests/DocumentationTests.fs b/tests/CSharpLanguageServer.Tests/DocumentationTests.fs index 30ac2fdc..73eef3aa 100644 --- a/tests/CSharpLanguageServer.Tests/DocumentationTests.fs +++ b/tests/CSharpLanguageServer.Tests/DocumentationTests.fs @@ -1,3 +1,5 @@ +module CSharpLanguageServer.Tests.DocumentationTests + open System open NUnit.Framework diff --git a/tests/CSharpLanguageServer.Tests/InitializationTests.fs b/tests/CSharpLanguageServer.Tests/InitializationTests.fs new file mode 100644 index 00000000..5e4a9437 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/InitializationTests.fs @@ -0,0 +1,32 @@ +module CSharpLanguageServer.Tests.InitializationTests + +open NUnit.Framework + +open CSharpLanguageServer.Tests.Tooling + +[] +let testServerRegistersCapabilitiesWithTheClient () = + let projectFiles = + Map.ofList [ + ("Project/Project.csproj", + """ + + Exe + net8.0 + + + """); + ("Project/Class.cs", + """using System; + class Class + { + } + """ + ) + ] + + use client = startAndMountServer projectFiles false + client.Start() + client.Initialize() + client.DumpRpcLog() + Assert.IsTrue(client.ServerDidInvoke("client/registerCapability")) diff --git a/tests/CSharpLanguageServer.Tests/Tooling.fs b/tests/CSharpLanguageServer.Tests/Tooling.fs new file mode 100644 index 00000000..a317bb87 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Tooling.fs @@ -0,0 +1,456 @@ +module CSharpLanguageServer.Tests.Tooling + +open System +open System.Collections.Generic +open System.IO +open System.Diagnostics +open System.Text +open System.Threading.Tasks + +open NUnit.Framework +open Newtonsoft.Json.Linq + + +let writeProjectDir (fileMap: Map) : string = + let tempDir = Path.Combine( + Path.GetTempPath(), + "CSharpLanguageServer.Tests." + System.DateTime.Now.Ticks.ToString()) + + Directory.CreateDirectory(tempDir) |> ignore + + for kv in fileMap do + let filename = Path.Combine(tempDir, kv.Key) + + if filename.Contains("/") then + let parts = kv.Key.Split("/") + if parts.Length > 2 then + failwith "more than 1 subdir is not supported" + + let fileDir = Path.Combine(tempDir, parts[0]) + + if not (Directory.Exists(fileDir)) then + Directory.CreateDirectory(fileDir) |> ignore + + use fileStream = File.Create(filename) + fileStream.Write(Encoding.UTF8.GetBytes(kv.Value)) + + tempDir + + +let rec deleteDirectory (path: string) = + if Directory.Exists(path) then + Directory.GetFileSystemEntries(path) + |> Array.iter (fun item -> + if File.Exists(item) then + File.Delete(item) + else + deleteDirectory item) + Directory.Delete(path) + + +type ClientServerRpcCallInfo = + { + Method: string + RpcCallMsg: JObject + } + + +type RpcEndpoint = Server | Client + +type RpcMessageLogEntry = + { + Source: RpcEndpoint + TimestampMS: int + Message: JObject + } + +type ClientState = + { + LoggingEnabled: bool + ProjectDir: string option + InitializeReplyChannel: AsyncReplyChannel option + ProcessStartInfo: ProcessStartInfo option + ServerProcess: Process option + ServerStderrLineReadTask: Task + ServerStdoutJsonRpcMessageRead: Task + NextRpcCallId: int + OutstandingServerRpcCalls: Map + SetupTimestamp: DateTime + RpcLog: RpcMessageLogEntry list + } + static member Default = { + LoggingEnabled = false + ProjectDir = None + InitializeReplyChannel = None + ProcessStartInfo = None + ServerProcess = None + ServerStderrLineReadTask = Task.CompletedTask + ServerStdoutJsonRpcMessageRead = Task.CompletedTask + NextRpcCallId = 1 + OutstandingServerRpcCalls = Map.empty + SetupTimestamp = DateTime.MinValue + RpcLog = [] + } + + +type ClientEvent = + | Setup + | ServerStartRequest of Map + | ServerStopRequest of AsyncReplyChannel + | InitializeRequest of AsyncReplyChannel + | ShutdownRequest + | ExitRequest + | ServerStderrLineRead of string option + | RpcMessageReceived of Result + | SendServerRpcCall of string * JObject + | ServerRpcCallResult of JObject + | ClientRpcCall of JValue * string * JObject + | SendClientRpcCallResult of JToken * JToken + | SendRpcMessage of JObject + | EmitLogMessage of DateTime * string * string + | GetRpcLog of AsyncReplyChannel + + +let makeServerProcessInfo projectTempDir = + let serverExe = Path.Combine(Environment.CurrentDirectory) + let tfm = Path.GetFileName(serverExe) + let buildMode = Path.GetFileName(Path.GetDirectoryName(serverExe)) + + let baseDir = + serverExe + |> Path.GetDirectoryName + |> Path.GetDirectoryName + |> Path.GetDirectoryName + |> Path.GetDirectoryName + |> Path.GetDirectoryName + + let baseServerFileName = + Path.Combine(baseDir, "src", "CSharpLanguageServer", "bin", buildMode, tfm, "CSharpLanguageServer") + + let serverFileName = + match System.Environment.OSVersion.Platform with + | PlatformID.Win32NT -> baseServerFileName + ".exe" + | _ -> baseServerFileName + + Assert.IsTrue(File.Exists(serverFileName)) + + let processStartInfo = new ProcessStartInfo() + processStartInfo.FileName <- serverFileName + processStartInfo.RedirectStandardInput <- true + processStartInfo.RedirectStandardOutput <- true + processStartInfo.RedirectStandardError <- true + processStartInfo.UseShellExecute <- false + processStartInfo.CreateNoWindow <- true + processStartInfo.WorkingDirectory <- projectTempDir + + processStartInfo + + +let processClientEvent (state: ClientState) (post: ClientEvent -> unit) msg : Async = async { + + let logMessage logger msg = + post (EmitLogMessage (DateTime.Now, logger, msg)) + + match msg with + | Setup -> + return { state with SetupTimestamp = DateTime.Now } + + | ServerStartRequest fileMap -> + let projectTempDir = writeProjectDir fileMap + let processStartInfo = makeServerProcessInfo projectTempDir + + let p = new Process() + p.StartInfo <- processStartInfo + + logMessage "ServerStartRequest" "StartServer: p.Start().." + let startResult = p.Start() + logMessage "ServerStartRequest" (String.Format("StartServer: p.Start(): {0}, {1}", startResult, p.Id)) + + post (RpcMessageReceived (Ok None)) + post (ServerStderrLineRead None) + + return { state with ProjectDir = Some projectTempDir + ProcessStartInfo = Some processStartInfo + ServerProcess = Some p } + + | ServerStopRequest rc -> + let p = state.ServerProcess.Value + logMessage "StopServer" "p.Kill().." + p.Kill() + logMessage "StopServer" "p.WaitForExit().." + p.WaitForExit() + logMessage "StopServer" "p.WaitForExit(): OK" + + deleteDirectory state.ProjectDir.Value + + logMessage "StopServer" (sprintf "exit code=%d" p.ExitCode) + + rc.Reply(()) + + return state + + | InitializeRequest rc -> + logMessage "InitializeRequest" "sending 'initialize'.." + + let initParams = JObject() + initParams["processId"] <- int (System.Diagnostics.Process.GetCurrentProcess().Id) + initParams["rootUri"] <- (sprintf "file://%s" state.ProjectDir.Value) + initParams["capabilities"] <- JObject() + initParams["trace"] <- JValue "off" + + post (SendServerRpcCall ("initialize", initParams)) + + return { state with InitializeReplyChannel = Some rc } + + | ShutdownRequest -> + logMessage "ShutdownRequest" "sending 'shutdown'.." + let initParams = JObject() + post (SendServerRpcCall ("shutdown", initParams)) + return state + + | ExitRequest -> + logMessage "ExitRequest" "sending 'exit'.." + let initParams = JObject() + post (SendServerRpcCall ("exit", initParams)) + return state + + | ServerStderrLineRead line -> + match line with + | Some line -> logMessage "StdErr" line + | None -> () + + let readNextStdErrLine () = + if not state.ServerProcess.Value.HasExited then + let line = state.ServerProcess.Value.StandardError.ReadLine() + post (ServerStderrLineRead (Some line)) + + let nextStdErrLineReadTask = Task.Run(readNextStdErrLine) + + return { state with ServerStderrLineReadTask = nextStdErrLineReadTask } + + | RpcMessageReceived result -> + let readJsonRpcHeader (stdout: Stream) = + let headerBytes = List() + + let terminatorReached () = + headerBytes.Count >= 4 + && headerBytes[headerBytes.Count - 4] = (byte '\r') + && headerBytes[headerBytes.Count - 3] = (byte '\n') + && headerBytes[headerBytes.Count - 2] = (byte '\r') + && headerBytes[headerBytes.Count - 1] = (byte '\n') + + while not (terminatorReached()) do + let b: int = stdout.ReadByte() + if b < 0 then + let failureMessage = (sprintf "readJsonRpcHeader: EOF, b=%d; bytes=%d; \"%s\"" (int b) (headerBytes.Count) (headerBytes.ToArray() |> Encoding.UTF8.GetString)) + failwith failureMessage + else + headerBytes.Add(byte b) + () + + let headers = + Encoding.UTF8.GetString(headerBytes.ToArray()) + |> _.Split("\r\n") + |> Seq.filter (fun s -> s.Length > 0) + |> Seq.map (_.Split(":")) + + let contentLength = + headers + |> Seq.filter (fun a -> a[0].ToLower().Trim() = "content-length") + |> Seq.map (fun a -> a[1] |> Int32.Parse) + |> Seq.head + + {| ContentLength = contentLength; ContentType = None |} + + let readNextJsonRpcMessage () = + try + let stdout = state.ServerProcess.Value.StandardOutput.BaseStream + if not stdout.CanRead then + failwith "stdout.CanRead = false" + let header = readJsonRpcHeader stdout + let content: byte[] = Array.zeroCreate header.ContentLength + let bytesRead = stdout.Read(content) + if bytesRead <> header.ContentLength then + failwith "readNextJsonRpcMessage: could not read full content" + + let msg = Encoding.UTF8.GetString(content) |> JObject.Parse + + post (RpcMessageReceived (Ok (Some msg))) + with ex -> + post (RpcMessageReceived (Error ex)) + + match result with + | Error e -> + logMessage "RpcMessageReceived" (sprintf "e=%s" (string e)) + return state + + | Ok rpcMsgMaybe -> + match rpcMsgMaybe with + | Some rpcMsg -> + if rpcMsg.ContainsKey("result") then + post (ServerRpcCallResult rpcMsg) + else if rpcMsg.ContainsKey("method") then + post (ClientRpcCall (rpcMsg["id"] :?> JValue, string rpcMsg["method"], rpcMsg["params"] :?> JObject)) + else + failwith (sprintf "RpcMessageReceived: unknown json rpc msg type: %s" (string rpcMsg)) + | None -> + () + + let nextJsonRpcMessageReadTask = Task.Run(readNextJsonRpcMessage) + + let newRpcLog = + match rpcMsgMaybe with + | Some rpcMsg -> state.RpcLog @ [{ Source = Server; TimestampMS = 0; Message = rpcMsg }] + | None -> state.RpcLog + + return { state with ServerStdoutJsonRpcMessageRead = nextJsonRpcMessageReadTask + RpcLog = newRpcLog } + + | ClientRpcCall (id, m, p) -> + match m with + | "window/showMessage" -> + logMessage "windows/showMessage" (String.Format("[{0}] \"{1}\"", p["type"], p["message"])) + | "$/progress" -> + logMessage "$/progress" (String.Format("({0}) \"{1}\"", p["value"]["kind"], p["value"]["message"])) + | "client/registerCapability" -> + logMessage "ClientRpcCall" (String.Format("client/registerCapability: registrations={0}", p["registrations"])) + post (SendClientRpcCallResult (id, null)) + | "workspace/configuration" -> + logMessage "ClientRpcCall" (String.Format("workspace/configuration: params={0}", p)) + post (SendClientRpcCallResult (id, new JArray())) + | "window/workDoneProgress/create" -> + logMessage "ClientRpcCall" (String.Format("window/workDoneProgress/create: params={0}", p)) + post (SendClientRpcCallResult (id, null)) + | _ -> + logMessage "ClientRpcCall" (String.Format("ClientRpcCall: unhandled method call: \"{0}\"", m)) + + return state + + | SendServerRpcCall (m, p) -> + let rpcCallId = state.NextRpcCallId + let msg = JObject() + msg["jsonrpc"] <- JValue "2.0" + msg["id"] <- JValue rpcCallId + msg["method"] <- JValue m + msg["params"] <- p + + post (SendRpcMessage msg) + + let newOutstandingServerRpcCalls = + state.OutstandingServerRpcCalls + |> Map.add rpcCallId { Method = m; RpcCallMsg = msg } + + return { state with OutstandingServerRpcCalls = newOutstandingServerRpcCalls + NextRpcCallId = state.NextRpcCallId + 1 } + + | ServerRpcCallResult result -> + let rpcCallId = int result["id"] + let rpcCall = state.OutstandingServerRpcCalls |> Map.find rpcCallId + + let newOutstandingServerRpcCalls = + state.OutstandingServerRpcCalls |> Map.remove rpcCallId + + // hack + match rpcCall.Method with + | "initialize" -> + let initializedParams = JObject() + post (SendServerRpcCall ("initialized", initializedParams)) + | "initialized" -> + state.InitializeReplyChannel.Value.Reply() + | _ -> () + + return { state with OutstandingServerRpcCalls = newOutstandingServerRpcCalls } + + | SendClientRpcCallResult (id, result) -> + let msg = JObject() + msg["jsonrpc"] <- JValue "2.0" + msg["id"] <- id + msg["result"] <- result + post (SendRpcMessage msg) + + return state + + | SendRpcMessage rpcMsg -> + let rpcMsgJson = (string rpcMsg) + + let serverStdin = state.ServerProcess.Value.StandardInput + let formattedMessage = String.Format("Content-Length: {0}\r\n\r\n{1}", rpcMsgJson.Length, rpcMsgJson) + serverStdin.Write(formattedMessage) + serverStdin.Flush() + + let newRpcLog = state.RpcLog @ [{ Source = Client; TimestampMS = 0; Message = rpcMsg }] + + return { state with RpcLog = newRpcLog } + + | EmitLogMessage (timestamp, logger, msg) -> + let offsetMs = int (timestamp - state.SetupTimestamp).TotalMilliseconds + let timestampFmt = if offsetMs >= 0 then (sprintf "+%06i" offsetMs) else (sprintf "%07i" offsetMs) + + if state.LoggingEnabled then + Console.Error.WriteLine("[{0} {1}] {2}", timestampFmt, logger, msg) + + return state + + | GetRpcLog rc -> + rc.Reply(state.RpcLog) + return state +} + + +let clientEventLoop (initialState: ClientState) (inbox: MailboxProcessor) = + let rec loop state = async { + let! msg = inbox.Receive() + let! newState = processClientEvent state inbox.Post msg + return! loop newState + } + + async { + try + do! loop initialState + with ex -> + Console.Error.WriteLine("clientEventLoop: failed {0}", ex) + raise ex + } + + +type ClientController (client: MailboxProcessor, projectFiles: Map) = + let logMessage m msg = + client.Post (EmitLogMessage (DateTime.Now, (sprintf "ClientActorController.%s" m), msg)) + + interface IDisposable with + member __.Dispose() = + logMessage "Dispose" "sending ServerStopRequest.." + client.PostAndReply(fun rc -> ServerStopRequest rc) + logMessage "Dispose" "OK, ServerStopRequest has finished" + + member __.Start () = + logMessage "Start" "sending ServerStartRequest" + client.Post(ServerStartRequest projectFiles) + + member __.Initialize () = + logMessage "Initialize" "sending InitializeRequest.." + client.PostAndReply(fun rc -> InitializeRequest rc) + logMessage "Initialize" "OK, InitializeRequest complete" + + member __.Shutdown () = + client.Post(ShutdownRequest) + client.Post(ExitRequest) + + member __.Stop () = + client.PostAndReply(fun rc -> ServerStopRequest rc) + + member __.DumpRpcLog () = + let rpcLog = client.PostAndReply(fun rc -> GetRpcLog rc) + for m in rpcLog do + logMessage "RpcLog" (sprintf "%s; %s" (string m.Source) (string m.Message)) + + member __.ServerDidInvoke (rpcMethod: string) = + let rpcLog = client.PostAndReply(fun rc -> GetRpcLog rc) + rpcLog |> Seq.exists (fun m -> m.Source = Server && (string m.Message["method"]) = rpcMethod) + + +let startAndMountServer (projectFiles: Map) (enableLogging: bool) = + let initialState = { ClientState.Default with LoggingEnabled = enableLogging } + let clientActor = MailboxProcessor.Start(clientEventLoop initialState) + clientActor.Post(Setup) + new ClientController (clientActor, projectFiles)