diff --git a/Project.toml b/Project.toml index aa2501c..e052f98 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "DebugAdapter" uuid = "17994d07-08fe-42cc-bc1b-7af499b1ea47" -version = "3.1.1-DEV" +version = "3.2.0-DEV" [deps] Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" diff --git a/README.md b/README.md index e03b5d1..1956b80 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,12 @@ filepath = joinpath(homedir(), "something.jl") # This is the filepath that shoul DebugAdapter.debug_code(session, mod, code, filepath) ``` + +`debug_code` sends a `terminated` event once the code has finished, and a client takes +that as "the debuggee has ended" and disconnects. To debug more than one piece of code in +one session, pass `notify_termination=false` on every call but the last: + +```julia +DebugAdapter.debug_code(session, mod, setup_code, setup_path; notify_termination=false) +DebugAdapter.debug_code(session, mod, code, filepath) +``` diff --git a/src/packagedef.jl b/src/packagedef.jl index c1f56e9..a883d93 100644 --- a/src/packagedef.jl +++ b/src/packagedef.jl @@ -163,7 +163,14 @@ function Base.run(debug_session::DebugSession, error_handler=nothing) debug_session.debug_engine = nothing - DAPRPC.send(endpoint, terminated_notification_type, TerminatedEventArguments(false)) + # `terminated` means the debuggee has ended, and a client that hears it + # ends the debug session and disconnects. A caller that is going to debug + # more code in this session — TestItemControllers runs a test item's + # `@testsnippet` setups and then its body, as separate chunks — therefore + # has to keep it back until the last one. + if get(next_cmd, :notify_termination, true) + DAPRPC.send(endpoint, terminated_notification_type, TerminatedEventArguments(false)) + end put!(debug_session.finished_execution, true) else @@ -181,10 +188,20 @@ function Base.run(debug_session::DebugSession, error_handler=nothing) end end -function debug_code(debug_session::DebugSession, mod::Module, code::String, filename::String) +""" + debug_code(debug_session, mod, code, filename; notify_termination=true) + +Debug `code` in `mod` and return once it has finished. + +`notify_termination=false` suppresses the `terminated` event that would otherwise be sent +when the code finishes. Pass it when more code is going to be debugged in the same session: +a client takes `terminated` as "the debuggee has ended" and disconnects, so a chunk that is +not the last one must not send it. +""" +function debug_code(debug_session::DebugSession, mod::Module, code::String, filename::String; notify_termination::Bool=true) fetch(debug_session.attached) - put!(debug_session.next_cmd, (cmd=:debug, mod=mod, code=code, filename=filename)) + put!(debug_session.next_cmd, (cmd=:debug, mod=mod, code=code, filename=filename, notify_termination=notify_termination)) take!(debug_session.finished_execution) end diff --git a/test/test_debugsession.jl b/test/test_debugsession.jl new file mode 100644 index 0000000..4fed2c9 --- /dev/null +++ b/test/test_debugsession.jl @@ -0,0 +1,113 @@ +@testsnippet DapClient begin + import Sockets, JSON + + """ + with_debug_session(f) -> (events, result) + + Run a `DebugSession` over a pipe with a minimal DAP client attached, hand `f` the + session so it can debug code on it, and return the events the client saw along with + whatever `f` returned. + + The client is only as complete as these tests need: it completes the handshake and + records every event it is sent. + """ + function with_debug_session(f) + # A loopback socket rather than the named pipe the real adapter is given, because + # the session only ever sees an `IO` and a port needs no cleanup or platform case. + port, server = Sockets.listenany(Sockets.localhost, 0) + + result = Ref{Any}(nothing) + + server_task = @async begin + conn = Sockets.accept(server) + session = DebugAdapter.DebugSession(conn) + session_task = @async DebugAdapter.run(session) + try + result[] = f(session) + finally + close(session) + wait(session_task) + end + end + + client = Sockets.connect(Sockets.localhost, port) + events = String[] + seq = Ref(0) + + function request(command, arguments=Dict{String,Any}()) + seq[] += 1 + payload = JSON.json(Dict{String,Any}( + "seq" => seq[], + "type" => "request", + "command" => command, + "arguments" => arguments, + )) + write(client, "Content-Length: $(sizeof(payload))\r\n\r\n", payload) + flush(client) + end + + reader = @async while isopen(client) + line = readline(client, keep=false) + startswith(line, "Content-Length:") || continue + n = parse(Int, strip(split(line, ':')[2])) + readline(client) # the blank line between header and body + msg = JSON.parse(String(read(client, n))) + msg["type"] == "event" && push!(events, msg["event"]) + end + + try + request("initialize", Dict{String,Any}("adapterID" => "julia")) + # The handshake is only ordered by what the adapter waits on, and `attach` + # fulfills what `debug_code` blocks on, so a short wait between the two is + # enough to keep them in order. + sleep(0.5) + request("attach", Dict{String,Any}("stopOnEntry" => false)) + sleep(0.5) + request("configurationDone", Dict{String,Any}()) + + wait(server_task) + sleep(0.5) + finally + close(client) + close(server) + end + + return (events, result[]) + end +end + +@testitem "debug_code reports termination once per session, not once per chunk" setup=[DapClient] begin + # `terminated` means the debuggee has ended, and a client that hears it ends the debug + # session and disconnects. TestItemControllers debugs a test item's `@testsnippet` + # setups and then its body as separate `debug_code` calls, so sending the event after + # the setup tore the session down before the body ever ran and breakpoints in the body + # were never hit (julia-testitems/TestItemRunner.jl#107). + module TerminationTarget + first_ran = false + second_ran = false + end + + events, _ = with_debug_session() do session + DebugAdapter.debug_code(session, TerminationTarget, "first_ran = true\n", "setup.jl"; notify_termination=false) + DebugAdapter.debug_code(session, TerminationTarget, "second_ran = true\n", "body.jl") + end + + # Both chunks ran, in the same session + @test TerminationTarget.first_ran == true + @test TerminationTarget.second_ran == true + + @test count(==("terminated"), events) == 1 +end + +@testitem "debug_code reports termination by default" setup=[DapClient] begin + module DefaultTerminationTarget + ran = false + end + + events, _ = with_debug_session() do session + DebugAdapter.debug_code(session, DefaultTerminationTarget, "ran = true\n", "body.jl") + end + + @test DefaultTerminationTarget.ran == true + @test count(==("terminated"), events) == 1 +end