From c4be9fdafd4abf96e2ba5952b5873b003fe30130 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:33:08 +0200 Subject: [PATCH] Add JSON 1 compatibility --- .github/workflows/juliaci.yml | 29 ++++++++ Project.toml | 2 +- src/DAPRPC/core.jl | 56 ++++++++++++--- src/DAPRPC/interface_def.jl | 36 +++++----- src/DAPRPC/jsoncompat.jl | 11 +++ src/DAPRPC/packagedef.jl | 1 + test/test_daprpc.jl | 121 ++++++++++++++++++++++++++++++++ test/test_json_serialization.jl | 84 ++++++++++++++++++++++ 8 files changed, 310 insertions(+), 30 deletions(-) create mode 100644 src/DAPRPC/jsoncompat.jl create mode 100644 test/test_daprpc.jl create mode 100644 test/test_json_serialization.jl diff --git a/.github/workflows/juliaci.yml b/.github/workflows/juliaci.yml index 6aa24b5..10d3cce 100644 --- a/.github/workflows/juliaci.yml +++ b/.github/workflows/juliaci.yml @@ -15,3 +15,32 @@ jobs: permissions: write-all secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} + + json-compat: + name: JSON ${{ matrix.json-version }} + # Only run where `github.ref` points at the code under test. On + # `issue_comment` it points at the default branch, so the job would test + # main while reporting its status on the pull request. + if: >- + github.event_name == 'push' || github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.feature == 'LintAndTest') + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + json-version: ["0.20", "0.21", "1.7"] + env: + DEBUGADAPTER_EXPECTED_JSON_VERSION: ${{ matrix.json-version }} + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: "1" + - uses: julia-actions/cache@v2 + - name: Install JSON ${{ matrix.json-version }} + # Pin so that the resolve `Pkg.test` runs for the test-only dependencies + # cannot quietly move JSON to a different version. The test suite + # asserts the version it actually loaded against the env var above. + run: | + julia --project=. -e 'using Pkg; Pkg.add(PackageSpec(name="JSON", version="${{ matrix.json-version }}")); Pkg.pin("JSON"); Pkg.status()' + - uses: julia-actions/julia-runtest@v1 diff --git a/Project.toml b/Project.toml index a3ab39d..81f1d2f 100644 --- a/Project.toml +++ b/Project.toml @@ -15,7 +15,7 @@ TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] -JSON = "0.20, 0.21" +JSON = "0.20, 0.21, 1" julia = "1" JuliaInterpreter = "0.8.5, 0.9, 0.10, 0.11" diff --git a/src/DAPRPC/core.jl b/src/DAPRPC/core.jl index 452ea6c..8bc1dd8 100644 --- a/src/DAPRPC/core.jl +++ b/src/DAPRPC/core.jl @@ -1,7 +1,11 @@ struct DAPError <: Exception msg::AbstractString + code::Int + data::Any end +DAPError(msg::AbstractString) = DAPError(msg, -32603, nothing) + mutable struct DAPEndpoint{IOIn <: IO,IOOut <: IO} pipe_in::IOIn pipe_out::IOOut @@ -9,7 +13,7 @@ mutable struct DAPEndpoint{IOIn <: IO,IOOut <: IO} out_msg_queue::Channel{Any} in_msg_queue::Channel{Any} - outstanding_requests::Dict{String,Channel{Any}} + outstanding_requests::Dict{Int,Channel{Any}} err_handler::Union{Nothing,Function} @@ -22,7 +26,7 @@ mutable struct DAPEndpoint{IOIn <: IO,IOOut <: IO} end DAPEndpoint(pipe_in, pipe_out, err_handler = nothing) = - DAPEndpoint(pipe_in, pipe_out, Channel{Any}(Inf), Channel{Any}(Inf), Dict{String,Channel{Any}}(), err_handler, :idle, nothing, nothing, 0) + DAPEndpoint(pipe_in, pipe_out, Channel{Any}(Inf), Channel{Any}(Inf), Dict{Int,Channel{Any}}(), err_handler, :idle, nothing, nothing, 0) function write_transport_layer(stream, response) response_utf8 = transcode(UInt8, response) @@ -102,7 +106,7 @@ function Base.run(x::DAPEndpoint) break end - message_dict = JSON.parse(message) + message_dict = _parse_json(message) if message_dict["type"] == "request" || message_dict["type"] == "event" try @@ -116,7 +120,7 @@ function Base.run(x::DAPEndpoint) end elseif message_dict["type"] == "response" # This must be a response - id_of_request = message_dict["request_seq"] + id_of_request = Int(message_dict["request_seq"]) channel_for_response = x.outstanding_requests[id_of_request] put!(channel_for_response, message_dict) @@ -151,7 +155,7 @@ function send_notification(x::DAPEndpoint, method::AbstractString, params) message = Dict("seq" => x.seq, "type" => "event", "event" => method, "body" => params) - message_json = JSON.json(message) + message_json = _json(message) put!(x.out_msg_queue, message_json) @@ -170,7 +174,7 @@ function send_request(x::DAPEndpoint, method::AbstractString, params) response_channel = Channel{Any}(1) x.outstanding_requests[x.seq] = response_channel - message_json = JSON.json(message) + message_json = _json(message) put!(x.out_msg_queue, message_json) @@ -179,13 +183,29 @@ function send_request(x::DAPEndpoint, method::AbstractString, params) if response["success"]==true return response["body"] elseif response["success"]==false - error_message = response["message"] - throw(DAPError(error_message)) + throw(dap_error(response)) else throw(DAPError("ERROR AT THE TRANSPORT LEVEL")) end end +# The structured `Message` in `body.error` is optional in DAP, and only it +# carries the error id and variables, so fall back to the short `message` the +# response is required to have when the peer did not send one. +function dap_error(response) + fallback = get(response, "message", "The request failed.") + + body = get(response, "body", nothing) + error_message = body isa AbstractDict ? get(body, "error", nothing) : nothing + error_message isa AbstractDict || return DAPError(fallback) + + return DAPError( + get(error_message, "format", fallback), + Int(get(error_message, "id", -32603)), + get(error_message, "variables", nothing), + ) +end + function get_next_message(endpoint::DAPEndpoint) check_dead_endpoint!(endpoint) @@ -215,7 +235,7 @@ function send_success_response(endpoint, original_request, result) response = Dict("seq" => endpoint.seq, "type" => "response", "request_seq" => original_request["seq"], "success" => true, "command" => original_request["command"], "body" => result) - response_json = JSON.json(response) + response_json = _json(response) put!(endpoint.out_msg_queue, response_json) end @@ -225,9 +245,23 @@ function send_error_response(endpoint, original_request, code, message, data) endpoint.seq += 1 - response = Dict("seq" => endpoint.seq, "request_seq" => original_request["seq"], "error" => Dict("code" => code, "message" => message, "data" => data)) + error_body = Dict{String,Any}("id" => code, "format" => message) + # `isnothing` postdates the Julia versions this package supports. + if data !== nothing + error_body["variables"] = data + end - response_json = JSON.json(response) + response = Dict( + "seq" => endpoint.seq, + "type" => "response", + "request_seq" => original_request["seq"], + "success" => false, + "command" => original_request["command"], + "message" => message, + "body" => Dict("error" => error_body), + ) + + response_json = _json(response) put!(endpoint.out_msg_queue, response_json) end diff --git a/src/DAPRPC/interface_def.jl b/src/DAPRPC/interface_def.jl index c1aea93..318f3df 100644 --- a/src/DAPRPC/interface_def.jl +++ b/src/DAPRPC/interface_def.jl @@ -1,24 +1,24 @@ abstract type Outbound end -function JSON.Writer.CompositeTypeWrapper(t::Outbound) - fns = collect(fieldnames(typeof(t))) - dels = Int[] - for i = 1:length(fns) - f = fns[i] - if getfield(t, f) isa Missing - push!(dels, i) - end - end - deleteat!(fns, dels) - JSON.Writer.CompositeTypeWrapper(t, Tuple(fns)) -end - +# Optional DAP fields are represented as `missing` and must be omitted from the +# wire format entirely, rather than serialized as `null`. +# +# We lower to a `Dict{String,Any}` rather than to a `NamedTuple` of the present +# fields: a `NamedTuple` would mint a distinct concrete type for every subset of +# non-missing fields, forcing the JSON writer to compile a fresh specialization +# for each shape a type happens to take. Types like `StackFrame` and `Variable` +# are sent with varying field sets while stepping, so that cost is paid over and +# over during an interactive session. A `Dict` also serializes as `{}` when no +# field is present, which every supported JSON.jl version handles. function JSON.lower(a::Outbound) - if nfields(a) > 0 - JSON.Writer.CompositeTypeWrapper(a) - else - nothing + nfields(a) == 0 && return nothing + + result = Dict{String,Any}() + for field in fieldnames(typeof(a)) + value = getfield(a, field) + ismissing(value) || (result[String(field)] = value) end + return result end function field_allows_missing(field::Expr) @@ -60,7 +60,7 @@ macro dict_readable(arg) end ) : nothing) - function $tname(dict::Dict) + function $tname(dict::AbstractDict) end end diff --git a/src/DAPRPC/jsoncompat.jl b/src/DAPRPC/jsoncompat.jl new file mode 100644 index 0000000..1d15000 --- /dev/null +++ b/src/DAPRPC/jsoncompat.jl @@ -0,0 +1,11 @@ +# Everything version-sensitive about our use of JSON.jl lives here, so that the +# assumptions we make about a given JSON.jl release are visible in one place. +# +# Read side: JSON 1.x parses objects into `JSON.Object` by default, while the +# rest of the package expects plain `Dict{String,Any}`, recursively. +_parse_json(value) = JSON.parse(value; dicttype=Dict{String,Any}) + +# Write side: we rely on `JSON.lower` (see `interface_def.jl`) being honored for +# our own types, and on `AbstractDict` serializing as a JSON object. Both hold +# for every version allowed by `[compat]`. +_json(value) = JSON.json(value) diff --git a/src/DAPRPC/packagedef.jl b/src/DAPRPC/packagedef.jl index a111b0b..d5daac9 100644 --- a/src/DAPRPC/packagedef.jl +++ b/src/DAPRPC/packagedef.jl @@ -1,5 +1,6 @@ export DAPEndpoint, send_notification, send_request, send_success_response, send_error_response +include("jsoncompat.jl") include("core.jl") include("typed.jl") include("interface_def.jl") diff --git a/test/test_daprpc.jl b/test/test_daprpc.jl new file mode 100644 index 0000000..97fe591 --- /dev/null +++ b/test/test_daprpc.jl @@ -0,0 +1,121 @@ +@testsnippet DapEndpointPair begin + import Sockets + + """ + endpoint_pair() -> (client, adapter, cleanup) + + Two `DAPEndpoint`s wired to each other over a loopback socket, both already + running, plus a function that tears the pair down. + """ + function endpoint_pair() + port, server = Sockets.listenany(Sockets.localhost, 0) + client_conn = Sockets.connect(Sockets.localhost, port) + adapter_conn = Sockets.accept(server) + + client = DebugAdapter.DAPRPC.DAPEndpoint(client_conn, client_conn) + adapter = DebugAdapter.DAPRPC.DAPEndpoint(adapter_conn, adapter_conn) + run(client) + run(adapter) + + return client, adapter, () -> begin + close(client) + close(adapter) + close(server) + end + end +end + +@testitem "DAPRPC routes responses back to the caller" setup=[DapEndpointPair] begin + client, adapter, cleanup = endpoint_pair() + + try + succeeded = Ref{Any}(nothing) + request_task = @async succeeded[] = DebugAdapter.DAPRPC.send_request(client, "runInTerminal", Dict{String,Any}()) + + incoming = DebugAdapter.DAPRPC.get_next_message(adapter) + @test incoming["type"] == "request" + @test incoming["command"] == "runInTerminal" + + DebugAdapter.DAPRPC.send_success_response(adapter, incoming, Dict{String,Any}("processId" => 17)) + wait(request_task) + @test succeeded[]["processId"] == 17 + + failure = Ref{Any}(nothing) + failing_task = @async try + DebugAdapter.DAPRPC.send_request(client, "evaluate", Dict{String,Any}()) + catch err + failure[] = err + end + + incoming = DebugAdapter.DAPRPC.get_next_message(adapter) + DebugAdapter.DAPRPC.send_error_response(adapter, incoming, 42, "boom", nothing) + wait(failing_task) + @test failure[] isa DebugAdapter.DAPRPC.DAPError + @test failure[].msg == "boom" + # The whole `Message` survives the round trip, not just its text. + @test failure[].code == 42 + @test failure[].data === nothing + + with_variables = Ref{Any}(nothing) + variables_task = @async try + DebugAdapter.DAPRPC.send_request(client, "evaluate", Dict{String,Any}()) + catch err + with_variables[] = err + end + + incoming = DebugAdapter.DAPRPC.get_next_message(adapter) + DebugAdapter.DAPRPC.send_error_response(adapter, incoming, 7, "no such variable", Dict("name" => "x")) + wait(variables_task) + @test with_variables[].code == 7 + @test with_variables[].data == Dict("name" => "x") + finally + cleanup() + end +end + +@testitem "DAPRPC error responses are valid DAP" begin + import Sockets + + # A bare socket on the far end, so that the exact bytes `send_error_response` + # puts on the wire can be inspected rather than an endpoint's view of them. + port, server = Sockets.listenany(Sockets.localhost, 0) + peer = Sockets.connect(Sockets.localhost, port) + conn = Sockets.accept(server) + + endpoint = DebugAdapter.DAPRPC.DAPEndpoint(conn, conn) + run(endpoint) + + try + request = Dict{String,Any}("seq" => 3, "type" => "request", "command" => "evaluate") + DebugAdapter.DAPRPC.send_error_response(endpoint, request, 42, "boom", nothing) + + header = readline(peer) + @test startswith(header, "Content-Length:") + length_of_body = parse(Int, strip(split(header, ':')[2])) + readline(peer) # the blank line between header and body + response = DebugAdapter.DAPRPC._parse_json(String(read(peer, length_of_body))) + + @test response["type"] == "response" + @test response["success"] === false + @test response["command"] == "evaluate" + @test response["request_seq"] == 3 + @test response["message"] == "boom" + @test response["body"]["error"]["id"] == 42 + @test response["body"]["error"]["format"] == "boom" + # `data` was `nothing`, and DAP has no null-valued `variables`. + @test !haskey(response["body"]["error"], "variables") + finally + close(endpoint) + close(peer) + close(server) + end +end + +@testitem "DAPRPC tolerates error responses without a structured message" begin + # `body.error` is optional in DAP, so a peer may send only the short + # `message`, and the id and variables are then simply not available. + minimal = DebugAdapter.DAPRPC.dap_error(Dict{String,Any}("success" => false, "message" => "cancelled")) + @test minimal.msg == "cancelled" + @test minimal.code == -32603 + @test minimal.data === nothing +end diff --git a/test/test_json_serialization.jl b/test/test_json_serialization.jl new file mode 100644 index 0000000..099e40d --- /dev/null +++ b/test/test_json_serialization.jl @@ -0,0 +1,84 @@ +@testitem "DAP protocol JSON serialization" begin + import JSON + + _parse = DebugAdapter.DAPRPC._parse_json + + empty_event = DebugAdapter.InitializedEventArguments() + @test _parse(JSON.json(empty_event)) === nothing + + # A type whose fields are all optional and all missing must still serialize + # as an object, not as `null` or an error. + @test JSON.json(DebugAdapter.Source()) == "{}" + @test JSON.json(DebugAdapter.ValueFormat()) == "{}" + + source = DebugAdapter.Source(name="example.jl", path="/tmp/example.jl") + output = DebugAdapter.OutputEventArguments( + category="stdout", + output="hello\n", + source=source, + line=12, + ) + + serialized = _parse(JSON.json(output)) + @test serialized == Dict{String,Any}( + "category" => "stdout", + "output" => "hello\n", + "source" => Dict{String,Any}( + "name" => "example.jl", + "path" => "/tmp/example.jl", + ), + "line" => 12, + ) + @test !haskey(serialized, "variablesReference") + @test !haskey(serialized["source"], "sourceReference") + + # Missing fields are omitted at every level of nesting, including inside + # arrays of protocol objects. + nested = DebugAdapter.Source( + name="parent.jl", + sources=[DebugAdapter.Source(name="child.jl", sourceReference=7)], + ) + nested_json = _parse(JSON.json(nested)) + @test nested_json == Dict{String,Any}( + "name" => "parent.jl", + "sources" => Any[Dict{String,Any}("name" => "child.jl", "sourceReference" => 7)], + ) +end + +@testitem "DAP protocol objects accept parsed JSON dictionaries" begin + import JSON + + parsed = JSON.parse(""" + { + "name": "parent.jl", + "sources": [ + {"name": "child.jl", "sourceReference": 7} + ] + } + """) + + source = DebugAdapter.Source(parsed) + @test source.name == "parent.jl" + @test source.path === missing + # Indexing rather than `only`, which postdates the Julia versions this + # package supports. + @test length(source.sources) == 1 + @test source.sources[1].name == "child.jl" + @test source.sources[1].sourceReference == 7 + + normalized = DebugAdapter.DAPRPC._parse_json("{\"type\":\"event\",\"body\":{}}") + @test normalized isa Dict{String,Any} + @test normalized["body"] isa Dict{String,Any} +end + +@testitem "JSON version under test" begin + import JSON + + # Set by the `json-compat` CI job. `Pkg.test` resolves in its own sandbox + # environment, so this is the only place that can confirm which JSON.jl + # version the suite actually ran against. + expected = get(ENV, "DEBUGADAPTER_EXPECTED_JSON_VERSION", "") + if !isempty(expected) + @test startswith(string(pkgversion(JSON)), expected * ".") + end +end