From 4979e00080c0f620534517956bb081d0bd2d0b7d Mon Sep 17 00:00:00 2001 From: Ademar Gonzalez Date: Sat, 22 Aug 2026 14:36:04 -0400 Subject: [PATCH] ik session: the persistent evaluation protocol Phase 6 of ADR 0009: the session protocol is done; the debug adapter stays proposed, deliberately not built, in the sense of ADR 0008. ik session is a persistent evaluation session over the same framed JSON-RPC transport as ik lsp -- the framing layer moves to a shared Jsonrpc module instead of a second copy. eval returns what the human REPL never separated: the value (with an inert flag), everything the program printed (captured so it travels in the response instead of corrupting the channel), or a structured error with message, rendered diagnostic, and check-shaped spans. Definitions persist across evals; reset re-bootstraps; the capability profile is honored. The interrupt story is the client's timeout killing the process -- honest, and sufficient until the trampoline learns cancellation. Known leak: raw standard-output ports bypass the Console capture. The extension gains Eval Selection in Session (persistent state, results and captured output in the output channel) and Restart Session; changing the profile restarts both servers so displayed authority stays actual authority. The adapter remains unbuilt because the protocol was never its hard part: stepping needs cancellation and suspension points in the CPS trampoline, breakpoints need the CLocated span map wired to those points, and the coverage view phase 5 deferred wants the same instrumentation. One design, to be taken whole. --- CLAUDE.md | 3 +- IronKernel.Tests/IronKernel.Tests.fsproj | 1 + IronKernel.Tests/SessionTests.fs | 114 +++++++++++ IronKernel/IronKernel.fsproj | 2 + IronKernel/Jsonrpc.fs | 111 +++++++++++ IronKernel/LanguageServer.fs | 98 +--------- IronKernel/Program.fs | 2 + IronKernel/Session.fs | 130 +++++++++++++ ...editor-tooling-from-the-runtime-outward.md | 42 +++- editors/vscode/README.md | 4 + editors/vscode/package.json | 26 ++- editors/vscode/src/extension.ts | 72 ++++++- editors/vscode/src/session.ts | 183 ++++++++++++++++++ editors/vscode/test/session.test.ts | 28 +++ 14 files changed, 710 insertions(+), 106 deletions(-) create mode 100644 IronKernel.Tests/SessionTests.fs create mode 100644 IronKernel/Jsonrpc.fs create mode 100644 IronKernel/Session.fs create mode 100644 editors/vscode/src/session.ts create mode 100644 editors/vscode/test/session.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 05ec3ca..e849251 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,8 @@ package format · 0004 compiling procedure bodies · 0005 mutable pairs · 0006 as abnormal passes · 0007 identity for derived combiners · 0008 caching compiled bodies (proposed, deliberately not built) · 0009 editor tooling from the runtime outward (`ik check --json`, environment enumeration, the `ik lsp` language -server, reader recovery, and the environment/profile views are its phases 1–5). +server, reader recovery, the environment/profile views, and the `ik session` +protocol are its phases 1–6; the debug adapter itself stays proposed). They record measurements and rejected options, not just decisions. When a prediction in one turns out wrong, correct it in place rather than leaving it — several carry diff --git a/IronKernel.Tests/IronKernel.Tests.fsproj b/IronKernel.Tests/IronKernel.Tests.fsproj index de5de79..9a1c988 100644 --- a/IronKernel.Tests/IronKernel.Tests.fsproj +++ b/IronKernel.Tests/IronKernel.Tests.fsproj @@ -28,6 +28,7 @@ + diff --git a/IronKernel.Tests/SessionTests.fs b/IronKernel.Tests/SessionTests.fs new file mode 100644 index 0000000..63f5357 --- /dev/null +++ b/IronKernel.Tests/SessionTests.fs @@ -0,0 +1,114 @@ +module IronKernel.Tests.SessionTests + +open System.IO +open System.Text +open System.Text.Json +open Xunit + +open IronKernel.Ast +open IronKernel.Session + +let private frame (stream: Stream) (json: string) = + let bytes = Encoding.UTF8.GetBytes json + let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length) + stream.Write(header, 0, header.Length) + stream.Write(bytes, 0, bytes.Length) + +let private readFrames (data: byte[]) = + let text = Encoding.UTF8.GetString data + let mutable index = 0 + let frames = ResizeArray() + while index < text.Length do + let headerEnd = text.IndexOf("\r\n\r\n", index, System.StringComparison.Ordinal) + let length = + text.Substring(index, headerEnd - index).Split("\r\n") + |> Array.pick (fun line -> + if line.StartsWith("Content-Length:", System.StringComparison.OrdinalIgnoreCase) then + Some(int (line.Substring("Content-Length:".Length).Trim())) + else None) + let bodyStart = Encoding.UTF8.GetByteCount(text.Substring(0, headerEnd)) + 4 + frames.Add(JsonDocument.Parse(Encoding.UTF8.GetString(data, bodyStart, length))) + index <- (Encoding.UTF8.GetString(data, 0, bodyStart + length)).Length + List.ofSeq frames + +let private session profile (messages: string list) = + use input = new MemoryStream() + messages |> List.iter (frame input) + input.Position <- 0L + use output = new MemoryStream() + let exitCode = runOn input output profile + Assert.Equal(0, exitCode) + readFrames (output.ToArray()) + +let private request id method' parameters = + sprintf """{"jsonrpc":"2.0","id":%d,"method":"%s","params":%s}""" (id: int) (method': string) (parameters: string) + +let private evalRequest id (code: string) = + request id "eval" (sprintf """{"code":%s}""" (JsonSerializer.Serialize code)) + +let private resultOf id (frames: JsonDocument list) = + frames + |> List.pick (fun frameDocument -> + let root = frameDocument.RootElement + match root.TryGetProperty "id" with + | true, value when value.ValueKind = JsonValueKind.Number && value.GetInt32() = id -> + match root.TryGetProperty "result" with + | true, result -> Some result + | _ -> Some(root.GetProperty "error") + | _ -> None) + +[] +let ``a session evaluates persistently and separates errors from values`` () = + let frames = + session Unrestricted + [ request 1 "initialize" "{}" + evalRequest 2 "(define x 40)\n(+ x 2)" + evalRequest 3 "x" + evalRequest 4 "(car 5)" + request 5 "reset" "{}" + evalRequest 6 "x" ] + Assert.Equal("unrestricted", (resultOf 1 frames).GetProperty("profile").GetString()) + + // A multi-form eval returns the last value; the define persists. + Assert.Equal("42", (resultOf 2 frames).GetProperty("value").GetString()) + Assert.Equal("40", (resultOf 3 frames).GetProperty("value").GetString()) + + // The phase-6 point: an error is a structured field, not stdout text. + let error = (resultOf 4 frames).GetProperty "error" + Assert.Contains("expected pair", error.GetProperty("message").GetString()) + let location = error.GetProperty("locations").[0] + Assert.Equal("session", location.GetProperty("file").GetString()) + Assert.Equal(1, location.GetProperty("range").GetProperty("start").GetProperty("line").GetInt32()) + let mutable value = Unchecked.defaultof + Assert.False((resultOf 4 frames).TryGetProperty("value", &value)) + + // Reset restores the bootstrap: the definition is gone. + let afterReset = resultOf 6 frames + Assert.Contains("unbound variable", afterReset.GetProperty("error").GetProperty("message").GetString()) + +[] +let ``printed output travels in the response not the channel`` () = + let frames = + session Unrestricted + [ evalRequest 1 "(print \"hello, session\")" ] + let result = resultOf 1 frames + Assert.Equal("hello, session", result.GetProperty("output").GetString()) + Assert.True(result.GetProperty("inert").GetBoolean()) + +[] +let ``the session honours the capability profile`` () = + let frames = + session Minimal + [ request 1 "initialize" "{}" + evalRequest 2 "(print \"denied\")" ] + Assert.Equal("minimal", (resultOf 1 frames).GetProperty("profile").GetString()) + // Under minimal there is no print binding at all: host output is authority. + let error = (resultOf 2 frames).GetProperty "error" + Assert.Contains("print", error.GetProperty("message").GetString()) + +[] +let ``unknown session requests get a method-not-found error`` () = + let frames = + session Unrestricted [ request 1 "step" "{}" ] + let root = frames.Head.RootElement + Assert.Equal(-32601, root.GetProperty("error").GetProperty("code").GetInt32()) diff --git a/IronKernel/IronKernel.fsproj b/IronKernel/IronKernel.fsproj index a7c79ab..ab2ce5b 100644 --- a/IronKernel/IronKernel.fsproj +++ b/IronKernel/IronKernel.fsproj @@ -44,6 +44,8 @@ + + diff --git a/IronKernel/Jsonrpc.fs b/IronKernel/Jsonrpc.fs new file mode 100644 index 0000000..e3e9702 --- /dev/null +++ b/IronKernel/Jsonrpc.fs @@ -0,0 +1,111 @@ +namespace IronKernel + +/// Content-Length framed JSON-RPC over streams: the transport the language +/// server (`ik lsp`) and the session protocol (`ik session`) share. A +/// deliberately small hand-rolled layer -- framing, requests, responses, +/// notifications -- kept dependency-free and testable against in-memory +/// streams (ADR 0009 phases 3 and 6). +module Jsonrpc = + + open System + open System.IO + open System.Text + open System.Text.Json + + let readFramed (input: Stream) : JsonDocument option = + let readLine () = + let builder = StringBuilder() + let mutable eof = false + let mutable finished = false + while not finished do + match input.ReadByte() with + | -1 -> + eof <- true + finished <- true + | 10 -> finished <- true + | 13 -> () + | b -> builder.Append(char b) |> ignore + if eof && builder.Length = 0 then None else Some(builder.ToString()) + let mutable contentLength = -1 + let rec readHeaders () = + match readLine () with + | None -> false + | Some "" -> true + | Some line -> + if line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) then + match Int32.TryParse(line.Substring("Content-Length:".Length).Trim()) with + | true, length -> contentLength <- length + | _ -> () + readHeaders () + if not (readHeaders ()) || contentLength < 0 then None + else + let buffer = Array.zeroCreate contentLength + let mutable filled = 0 + let mutable ok = true + while ok && filled < contentLength do + let count = input.Read(buffer, filled, contentLength - filled) + if count <= 0 then ok <- false else filled <- filled + count + if ok then Some(JsonDocument.Parse(ReadOnlyMemory buffer)) else None + + let writeFramed (output: Stream) (json: string) = + let bytes = Encoding.UTF8.GetBytes json + let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length) + output.Write(header, 0, header.Length) + output.Write(bytes, 0, bytes.Length) + output.Flush() + + let toJson (write: Utf8JsonWriter -> unit) = + use stream = new MemoryStream() + use writer = new Utf8JsonWriter(stream) + write writer + writer.Flush() + Encoding.UTF8.GetString(stream.ToArray()) + + let respond output (id: JsonElement) (writeResult: Utf8JsonWriter -> unit) = + toJson (fun writer -> + writer.WriteStartObject() + writer.WriteString("jsonrpc", "2.0") + writer.WritePropertyName "id" + id.WriteTo writer + writer.WritePropertyName "result" + writeResult writer + writer.WriteEndObject()) + |> writeFramed output + + let respondError output (id: JsonElement) code (message: string) = + toJson (fun writer -> + writer.WriteStartObject() + writer.WriteString("jsonrpc", "2.0") + writer.WritePropertyName "id" + id.WriteTo writer + writer.WritePropertyName "error" + writer.WriteStartObject() + writer.WriteNumber("code", (code: int)) + writer.WriteString("message", message) + writer.WriteEndObject() + writer.WriteEndObject()) + |> writeFramed output + + let notify output (method: string) (writeParams: Utf8JsonWriter -> unit) = + toJson (fun writer -> + writer.WriteStartObject() + writer.WriteString("jsonrpc", "2.0") + writer.WriteString("method", method) + writer.WritePropertyName "params" + writeParams writer + writer.WriteEndObject()) + |> writeFramed output + + /// The method name, id (when present), and params of one message. + let envelope (document: JsonDocument) = + let root = document.RootElement + let methodName = + match root.TryGetProperty "method" with + | true, value -> value.GetString() + | _ -> "" + let hasId, id = root.TryGetProperty "id" + let parameters = + match root.TryGetProperty "params" with + | true, value -> value + | _ -> JsonDocument.Parse("null").RootElement + methodName, hasId, id, parameters diff --git a/IronKernel/LanguageServer.fs b/IronKernel/LanguageServer.fs index 263bbb0..9ede056 100644 --- a/IronKernel/LanguageServer.fs +++ b/IronKernel/LanguageServer.fs @@ -18,92 +18,7 @@ module LanguageServer = open System.Text.Json open Ast open Errors - - // ---- Framing ---------------------------------------------------------- - - let private readFramed (input: Stream) : JsonDocument option = - let readLine () = - let builder = StringBuilder() - let mutable eof = false - let mutable finished = false - while not finished do - match input.ReadByte() with - | -1 -> - eof <- true - finished <- true - | 10 -> finished <- true - | 13 -> () - | b -> builder.Append(char b) |> ignore - if eof && builder.Length = 0 then None else Some(builder.ToString()) - let mutable contentLength = -1 - let rec readHeaders () = - match readLine () with - | None -> false - | Some "" -> true - | Some line -> - if line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) then - match Int32.TryParse(line.Substring("Content-Length:".Length).Trim()) with - | true, length -> contentLength <- length - | _ -> () - readHeaders () - if not (readHeaders ()) || contentLength < 0 then None - else - let buffer = Array.zeroCreate contentLength - let mutable filled = 0 - let mutable ok = true - while ok && filled < contentLength do - let count = input.Read(buffer, filled, contentLength - filled) - if count <= 0 then ok <- false else filled <- filled + count - if ok then Some(JsonDocument.Parse(ReadOnlyMemory buffer)) else None - - let private writeFramed (output: Stream) (json: string) = - let bytes = Encoding.UTF8.GetBytes json - let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length) - output.Write(header, 0, header.Length) - output.Write(bytes, 0, bytes.Length) - output.Flush() - - let private toJson (write: Utf8JsonWriter -> unit) = - use stream = new MemoryStream() - use writer = new Utf8JsonWriter(stream) - write writer - writer.Flush() - Encoding.UTF8.GetString(stream.ToArray()) - - let private respond output (id: JsonElement) (writeResult: Utf8JsonWriter -> unit) = - toJson (fun writer -> - writer.WriteStartObject() - writer.WriteString("jsonrpc", "2.0") - writer.WritePropertyName "id" - id.WriteTo writer - writer.WritePropertyName "result" - writeResult writer - writer.WriteEndObject()) - |> writeFramed output - - let private respondError output (id: JsonElement) code (message: string) = - toJson (fun writer -> - writer.WriteStartObject() - writer.WriteString("jsonrpc", "2.0") - writer.WritePropertyName "id" - id.WriteTo writer - writer.WritePropertyName "error" - writer.WriteStartObject() - writer.WriteNumber("code", (code: int)) - writer.WriteString("message", message) - writer.WriteEndObject() - writer.WriteEndObject()) - |> writeFramed output - - let private notify output (method: string) (writeParams: Utf8JsonWriter -> unit) = - toJson (fun writer -> - writer.WriteStartObject() - writer.WriteString("jsonrpc", "2.0") - writer.WriteString("method", method) - writer.WritePropertyName "params" - writeParams writer - writer.WriteEndObject()) - |> writeFramed output + open Jsonrpc // ---- Positions -------------------------------------------------------- @@ -526,16 +441,7 @@ module LanguageServer = | None -> running <- false | Some document -> use document = document - let root = document.RootElement - let methodName = - match root.TryGetProperty "method" with - | true, value -> value.GetString() - | _ -> "" - let hasId, id = root.TryGetProperty "id" - let parameters = - match root.TryGetProperty "params" with - | true, value -> value - | _ -> JsonDocument.Parse("null").RootElement + let methodName, hasId, id, parameters = envelope document match methodName with | "initialize" -> respond output id (fun writer -> diff --git a/IronKernel/Program.fs b/IronKernel/Program.fs index a232dc9..35aaf06 100644 --- a/IronKernel/Program.fs +++ b/IronKernel/Program.fs @@ -16,6 +16,7 @@ let private usage = ironkernel [--profile ] run [args...] Run a .ikr script or .ikc package ironkernel [--profile ] check [--json] [ | project.ikproj] ironkernel [--profile ] lsp Start the language server (stdio) + ironkernel [--profile ] session Start an evaluation session (stdio) ironkernel [--profile ] compile [-o ] ironkernel [--profile ] compile --managed [-o ] ironkernel --profile compile --native [-o ] @@ -162,6 +163,7 @@ let private dispatch (profileOverride: CapabilityProfile option) args = // Non-source tokens (including flags) are project program args, not scripts. withProject profileOverride None (fun project -> ProjectTool.run project scriptArgs) | ["lsp"] -> IronKernel.LanguageServer.run profile + | ["session"] -> IronKernel.Session.run profile | "check" :: rest -> let json = List.contains "--json" rest let arguments = rest |> List.filter (fun argument -> argument <> "--json") diff --git a/IronKernel/Session.fs b/IronKernel/Session.fs new file mode 100644 index 0000000..7cf4085 --- /dev/null +++ b/IronKernel/Session.fs @@ -0,0 +1,130 @@ +namespace IronKernel + +/// `ik session`: a persistent evaluation session over Content-Length framed +/// JSON-RPC on stdio -- the protocol ADR 0009 phase 6 puts underneath +/// everything interactive: editor eval, the inspector's remote-eval, and +/// eventually the debug adapter. The point phase 6 opens with: results and +/// errors are separate, structured values on the channel, which the human +/// REPL never offered -- it folds errors into stdout as `error : ...` text. +/// +/// One session is one client. Evaluation runs on the loop, so a +/// non-terminating program blocks the session; the client owns the timeout +/// and restarts the process, which is also the interrupt story until the +/// trampoline learns cancellation. +module Session = + + open System + open System.IO + open System.Text.Json + open Ast + open Errors + open Jsonrpc + + let private profileName = function + | Minimal -> "minimal" + | Safe -> "safe" + | Unrestricted -> "unrestricted" + + let private writePosition (writer: Utf8JsonWriter) (position: SourcePosition) = + writer.WriteStartObject() + writer.WriteNumber("line", position.line) + writer.WriteNumber("column", position.column) + writer.WriteEndObject() + + /// Locations use the same 1-based, end-exclusive shape as `ik check --json`. + let private writeLocations (writer: Utf8JsonWriter) locations = + writer.WritePropertyName "locations" + writer.WriteStartArray() + for span, _ in locations do + writer.WriteStartObject() + writer.WriteString("file", (span: SourceSpan).sourceName) + writer.WritePropertyName "range" + writer.WriteStartObject() + writer.WritePropertyName "start" + writePosition writer span.startPosition + writer.WritePropertyName "end" + writePosition writer span.endPosition + writer.WriteEndObject() + writer.WriteEndObject() + writer.WriteEndArray() + + /// Evaluate every form in `code`, capturing what the program prints so it + /// travels in the response instead of corrupting the framed channel. + /// Raw standard-output ports still bypass the capture -- a limitation the + /// debug adapter's stdio discipline will have to close. + let private evaluate env (code: string) = + let previousOut = Console.Out + use captured = new StringWriter() + Console.SetOut captured + let outcome = + try + try + Emit.runSource env "session" code + with ex -> + Choice1Of2(ClrException ex) + finally + Console.SetOut previousOut + outcome, captured.ToString() + + let runOn (input: Stream) (output: Stream) profile : int = + match Emit.bootstrapEnvForProfile profile with + | Choice1Of2 error -> + eprintfn "Startup error: %s" (showError error) + 1 + | Choice2Of2 initialEnv -> + let mutable env = initialEnv + let mutable running = true + while running do + match readFramed input with + | None -> running <- false + | Some document -> + use document = document + let methodName, hasId, id, parameters = envelope document + match methodName with + | "initialize" -> + respond output id (fun writer -> + writer.WriteStartObject() + writer.WriteString("name", "IronKernel") + writer.WriteString("version", Repl.version) + writer.WriteString("profile", profileName profile) + writer.WriteEndObject()) + | "eval" -> + let code = parameters.GetProperty("code").GetString() + let outcome, printed = evaluate env code + respond output id (fun writer -> + writer.WriteStartObject() + writer.WriteString("output", printed) + (match outcome with + | Choice2Of2 value -> + let inert = + match value with + | Inert -> true + | _ -> false + writer.WriteBoolean("inert", inert) + writer.WriteString("value", showVal value) + | Choice1Of2 error -> + let locations, core = errorLocations error + writer.WritePropertyName "error" + writer.WriteStartObject() + writer.WriteString("message", errorMessage core) + writer.WriteString("rendered", showError error) + writeLocations writer locations + writer.WriteEndObject()) + writer.WriteEndObject()) + | "reset" -> + match Emit.bootstrapEnvForProfile profile with + | Choice2Of2 fresh -> + env <- fresh + respond output id (fun writer -> + writer.WriteStartObject() + writer.WriteEndObject()) + | Choice1Of2 error -> + respondError output id -32000 (showError error) + | "exit" -> running <- false + | other when hasId -> + respondError output id -32601 (sprintf "method not found: %s" other) + | _ -> () + 0 + + let run profile : int = + runOn (Console.OpenStandardInput()) (Console.OpenStandardOutput()) profile diff --git a/docs/adr/0009-editor-tooling-from-the-runtime-outward.md b/docs/adr/0009-editor-tooling-from-the-runtime-outward.md index 027ef33..8347044 100644 --- a/docs/adr/0009-editor-tooling-from-the-runtime-outward.md +++ b/docs/adr/0009-editor-tooling-from-the-runtime-outward.md @@ -1,7 +1,7 @@ # ADR 0009: Editor tooling grows from the runtime outward -Status: Accepted — phases 1 through 5 implemented (phase 5's third item -deferred behind phase 6) +Status: Accepted — phases 1 through 5 implemented, and phase 6's session +protocol; the debug adapter itself is proposed, deliberately not built yet ## Decision @@ -212,10 +212,40 @@ phase 6's session protocol. Deferred with that dependency named, so the "nearly free" framing does not get re-derived from the `CLocated` line above. -**Phase 6 — debug adapter.** Requires a persistent session protocol that does -not exist — the REPL cannot even separate errors from values today — so the -protocol is the prerequisite, and it should serve the inspector and -remote-eval too, not just the DAP. +**Phase 6 — the session protocol; the debug adapter stays proposed.** *The +protocol is done; the adapter is deliberately not built yet, in the sense of +ADR 0008.* + +`ik session` is a persistent evaluation session over the same framed +JSON-RPC transport as `ik lsp` — the framing layer is now a shared +`Jsonrpc` module rather than a second copy. `eval` returns what the human +REPL never separated: the value (with an inert flag), everything the +program printed (captured so it travels in the response instead of +corrupting the channel), or a structured error — message, rendered +diagnostic, and spans in the same 1-based shape as `ik check --json`. +Definitions persist across evals; `reset` re-bootstraps; the capability +profile is honored (`--profile minimal` has no `print` to deny). The +interrupt story is owned by the client: a hung evaluation cannot be +cancelled, so the timeout kills the process and the next eval starts +fresh — honest, and sufficient until the trampoline learns cancellation. +One known leak: raw standard-output ports write past the `Console` +capture; the adapter's stdio discipline will have to close that. + +In the editor: **Eval Selection in Session** (persistent state, results +and captured output in the output channel, structured errors rendered +with their carets) and **Restart Session**; changing the profile restarts +both servers so displayed authority stays actual authority. + +Two scope notes. The inspector's "eval in this env" still has no +per-frame form — the session evaluates in its own ground environment, and +addressing an arbitrary frame needs an environment-handle op on the +protocol, which is the protocol's natural next verb. And the debug +adapter remains unbuilt because the protocol was never its hard part: +stepping requires cancellation and suspension points in the CPS +trampoline, breakpoints need the `CLocated` span map wired to those +points, and the coverage view phase 5 deferred wants the same runtime +instrumentation. Those are one design, and it should be taken whole +rather than bolted on verb by verb. ## Alternatives diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 470eebd..3f0aabb 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -21,6 +21,9 @@ The extension never implements a second evaluator in JavaScript. - **Profile status bar**: the effective capability profile at a glance, flagging when it overrides the project's own ``; click to switch (the language server restarts to match) +- **Eval Selection in Session**: a persistent `ik session` process with + results, captured output, and structured errors in the output channel; + state survives between evals, a hung eval restarts the session - **Run Current File** and **Compile Current File to IKC** commands - **Run Project** and **Build Project** for `.ikproj` (nearest project, picker, or explorer context menu) - Check on save: `ik check --json` runs on saved `.ikr` files and publishes @@ -73,6 +76,7 @@ host authority available to editor commands and playground runs. - `IronKernel: Compile Current File to IKC` - `IronKernel: Run Project` — `run ` (plus `ironkernel.runArgs`) - `IronKernel: Build Project` — `build ` → `bin/*.ikc` +- `IronKernel: Eval Selection in Session` / `IronKernel: Restart Session` - `IronKernel: Refresh Environment View` - `IronKernel: Select Capability Profile` - `IronKernel: Open Playground` diff --git a/editors/vscode/package.json b/editors/vscode/package.json index e4f7625..b828689 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -39,7 +39,9 @@ "onCommand:ironkernel.openPlayground", "onCommand:ironkernel.refreshEnvironment", "onCommand:ironkernel.selectProfile", - "onView:ironkernelEnvironment" + "onView:ironkernelEnvironment", + "onCommand:ironkernel.evalSelection", + "onCommand:ironkernel.restartSession" ], "capabilities": { "untrustedWorkspaces": { @@ -136,6 +138,16 @@ "command": "ironkernel.selectProfile", "title": "IronKernel: Select Capability Profile", "category": "IronKernel" + }, + { + "command": "ironkernel.evalSelection", + "title": "IronKernel: Eval Selection in Session", + "category": "IronKernel" + }, + { + "command": "ironkernel.restartSession", + "title": "IronKernel: Restart Session", + "category": "IronKernel" } ], "menus": { @@ -168,6 +180,11 @@ "command": "ironkernel.buildProject", "when": "resourceExtname == .ikproj && isWorkspaceTrusted", "group": "ironkernel@2" + }, + { + "command": "ironkernel.evalSelection", + "when": "editorLangId == ironkernel && isWorkspaceTrusted", + "group": "ironkernel@3" } ], "view/title": [ @@ -260,6 +277,13 @@ "minimum": 4096, "maximum": 16777216, "description": "Maximum stdout or stderr retained for one execution." + }, + "ironkernel.session.timeoutMs": { + "type": "number", + "default": 10000, + "minimum": 1000, + "maximum": 600000, + "description": "Maximum time one session evaluation may take. A hung evaluation kills the session process (its definitions are lost) and the next eval starts fresh." } } }, diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index b4ddc46..527fb77 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -13,6 +13,7 @@ import { type CheckLocation } from "./diagnostics.js"; import { EnvironmentViewProvider, type EnvironmentReport } from "./environmentView.js"; +import { SessionClient, type SessionEvalResult } from "./session.js"; import { PlaygroundPanel } from "./playgroundPanel.js"; import { ProfileStatus } from "./profileStatus.js"; import { findIkprojWalkingUp, isIkprojPath, rankIkProjects } from "./projects.js"; @@ -70,6 +71,49 @@ export function activate(context: vscode.ExtensionContext): void { .update("profile", picked, vscode.ConfigurationTarget.Workspace); } }), + vscode.commands.registerCommand("ironkernel.evalSelection", async () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== "ironkernel") { + void vscode.window.showInformationMessage("Open an IronKernel source file first."); + return; + } + if (!(await requireTrustedWorkspace())) { + return; + } + const selection = editor.selection; + const code = selection.isEmpty + ? editor.document.lineAt(selection.active.line).text + : editor.document.getText(selection); + if (code.trim() === "") { + return; + } + const client = await obtainSession(editor.document.uri, output); + if (!client) { + return; + } + output.show(true); + output.appendLine(`session> ${code.trim()}`); + try { + const result: SessionEvalResult = await client.eval(code); + if (result.output) { + output.append(result.output.endsWith("\n") ? result.output : result.output + "\n"); + } + if (result.error) { + output.appendLine(result.error.rendered); + } else if (!result.inert && result.value !== undefined) { + output.appendLine(result.value); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + output.appendLine(`[session] ${message}`); + void vscode.window.showWarningMessage(message); + } + }), + vscode.commands.registerCommand("ironkernel.restartSession", () => { + sessionClient?.kill(); + sessionClient = undefined; + output.appendLine("[session] restarted; definitions were discarded"); + }), vscode.window.onDidChangeActiveTextEditor(() => { void profileStatus.update(); void refreshEnvironment(); @@ -77,9 +121,11 @@ export function activate(context: vscode.ExtensionContext): void { vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("ironkernel.profile")) { void profileStatus.update(); - // The server was launched with the old --profile; restart it so the - // session environment carries the authority the status bar shows. + // Both servers were launched with the old --profile; restart them so + // the authority shown is the authority actually carried. void restartLanguageServer(output).then(afterServerChange); + sessionClient?.kill(); + sessionClient = undefined; } }), vscode.workspace.onDidGrantWorkspaceTrust(() => { @@ -293,6 +339,26 @@ async function saveIronKernelDocumentsNear(projectPath: string): Promise { let languageClient: LanguageClient | undefined; +let sessionClient: SessionClient | undefined; + +async function obtainSession( + scopeUri: vscode.Uri, + output: vscode.OutputChannel +): Promise { + if (sessionClient) { + return sessionClient; + } + const resolved = await resolveConfiguredRuntime(scopeUri, output); + if (!resolved) { + return undefined; + } + const timeout = vscode.workspace + .getConfiguration("ironkernel", scopeUri) + .get("session.timeoutMs", 10000); + sessionClient = new SessionClient(resolved.runtime, timeout); + return sessionClient; +} + async function restartLanguageServer(output: vscode.OutputChannel): Promise { const client = languageClient; languageClient = undefined; @@ -564,6 +630,8 @@ function publishDiagnostics( export function deactivate(): Thenable | undefined { // Other resources registered in ExtensionContext are disposed by VS Code. + sessionClient?.dispose(); + sessionClient = undefined; const client = languageClient; languageClient = undefined; return client?.stop(); diff --git a/editors/vscode/src/session.ts b/editors/vscode/src/session.ts new file mode 100644 index 0000000..4230433 --- /dev/null +++ b/editors/vscode/src/session.ts @@ -0,0 +1,183 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { RuntimeCommand } from "./cli.js"; + +export function encodeFrame(json: string): Buffer { + const body = Buffer.from(json, "utf8"); + return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body]); +} + +/** Incremental Content-Length frame decoder; push chunks, get JSON bodies. */ +export class FrameDecoder { + private buffer = Buffer.alloc(0); + + push(chunk: Buffer): string[] { + this.buffer = Buffer.concat([this.buffer, chunk]); + const bodies: string[] = []; + for (;;) { + const headerEnd = this.buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + break; + } + const headers = this.buffer.subarray(0, headerEnd).toString("ascii"); + const match = /content-length:\s*(\d+)/i.exec(headers); + if (!match) { + // Unframeable garbage; drop the broken header and resynchronize. + this.buffer = this.buffer.subarray(headerEnd + 4); + continue; + } + const length = Number.parseInt(match[1] ?? "0", 10); + const frameEnd = headerEnd + 4 + length; + if (this.buffer.length < frameEnd) { + break; + } + bodies.push(this.buffer.subarray(headerEnd + 4, frameEnd).toString("utf8")); + this.buffer = this.buffer.subarray(frameEnd); + } + return bodies; + } +} + +export interface SessionErrorLocation { + file: string; + range: { start: { line: number; column: number }; end: { line: number; column: number } }; +} + +export interface SessionEvalResult { + output: string; + value?: string; + inert?: boolean; + error?: { message: string; rendered: string; locations: SessionErrorLocation[] }; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: NodeJS.Timeout; +} + +/** + * One `ik session` child process: persistent evaluation with structured + * results. The client owns the timeout — a hung evaluation kills the process + * and the next request starts a fresh session (losing its definitions, which + * the caller should surface). + */ +export class SessionClient { + private child: ChildProcessWithoutNullStreams | undefined; + private decoder = new FrameDecoder(); + private readonly pending = new Map(); + private nextId = 1; + + constructor( + private readonly runtime: RuntimeCommand, + private readonly requestTimeoutMs: number + ) {} + + get running(): boolean { + return this.child !== undefined; + } + + private start(): ChildProcessWithoutNullStreams { + const child = spawn(this.runtime.command, [...this.runtime.prefixArgs, "session"], { + cwd: this.runtime.cwd, + shell: false, + windowsHide: true + }); + this.decoder = new FrameDecoder(); + child.stdout.on("data", (chunk: Buffer) => { + for (const body of this.decoder.push(chunk)) { + this.dispatch(body); + } + }); + child.on("close", () => { + if (this.child === child) { + this.child = undefined; + } + this.rejectAll(new Error("The IronKernel session ended.")); + }); + child.on("error", () => { + if (this.child === child) { + this.child = undefined; + } + this.rejectAll(new Error("The IronKernel session could not start.")); + }); + this.child = child; + return child; + } + + private dispatch(body: string): void { + let parsed: { id?: number; result?: unknown; error?: { message?: string } }; + try { + parsed = JSON.parse(body) as typeof parsed; + } catch { + return; + } + if (typeof parsed.id !== "number") { + return; + } + const waiting = this.pending.get(parsed.id); + if (!waiting) { + return; + } + this.pending.delete(parsed.id); + clearTimeout(waiting.timer); + if (parsed.error) { + waiting.reject(new Error(parsed.error.message ?? "session protocol error")); + } else { + waiting.resolve(parsed.result); + } + } + + private rejectAll(reason: Error): void { + for (const [, waiting] of this.pending) { + clearTimeout(waiting.timer); + waiting.reject(reason); + } + this.pending.clear(); + } + + request(method: string, params: unknown): Promise { + const child = this.child ?? this.start(); + const id = this.nextId; + this.nextId += 1; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + // A hung evaluation cannot be interrupted; killing the session is + // the interrupt story, and the next request begins a fresh one. + this.kill(); + reject(new Error(`The session did not answer within ${this.requestTimeoutMs} ms and was restarted.`)); + }, this.requestTimeoutMs); + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + timer + }); + child.stdin.write( + encodeFrame(JSON.stringify({ jsonrpc: "2.0", id, method, params })) + ); + }); + } + + eval(code: string): Promise { + return this.request("eval", { code }); + } + + kill(): void { + const child = this.child; + this.child = undefined; + if (child) { + child.kill(); + } + this.rejectAll(new Error("The IronKernel session was restarted.")); + } + + dispose(): void { + const child = this.child; + this.child = undefined; + if (child) { + child.stdin.write(encodeFrame(JSON.stringify({ jsonrpc: "2.0", method: "exit" }))); + setTimeout(() => child.kill(), 500); + } + this.rejectAll(new Error("The IronKernel session was disposed.")); + } +} diff --git a/editors/vscode/test/session.test.ts b/editors/vscode/test/session.test.ts new file mode 100644 index 0000000..398e9f6 --- /dev/null +++ b/editors/vscode/test/session.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { FrameDecoder, encodeFrame } from "../src/session.js"; + +describe("session framing", () => { + it("round-trips a frame, including multibyte content", () => { + const decoder = new FrameDecoder(); + const body = JSON.stringify({ value: "λϝ → 42" }); + expect(decoder.push(encodeFrame(body))).toEqual([body]); + }); + + it("reassembles frames split across chunks and batches", () => { + const decoder = new FrameDecoder(); + const first = JSON.stringify({ id: 1 }); + const second = JSON.stringify({ id: 2, value: "ok" }); + const stream = Buffer.concat([encodeFrame(first), encodeFrame(second)]); + const cut = encodeFrame(first).length - 3; + expect(decoder.push(stream.subarray(0, 5))).toEqual([]); + expect(decoder.push(stream.subarray(5, cut))).toEqual([]); + expect(decoder.push(stream.subarray(cut))).toEqual([first, second]); + }); + + it("resynchronizes past a garbage header", () => { + const decoder = new FrameDecoder(); + const body = JSON.stringify({ ok: true }); + const noise = Buffer.from("Warning: something\r\n\r\n", "ascii"); + expect(decoder.push(Buffer.concat([noise, encodeFrame(body)]))).toEqual([body]); + }); +});