From 2acc2e1aefa73cb70af1226ffcd3127615699a54 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Wed, 19 Aug 2026 09:03:32 -0700 Subject: [PATCH 1/3] Let debug_code hold back the terminated event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `terminated` means the debuggee has ended, and a client that hears it ends the debug session and disconnects. Sending it after every chunk of debugged code makes a session usable exactly once. TestItemControllers debugs a test item's `@testsnippet` setups and then its body as separate `debug_code` calls on one session, so the event sent after the setup tore the session down before the body ran, and breakpoints in the body were never hit — julia-testitems/TestItemRunner.jl#107. `debug_code(...; notify_termination=false)` suppresses the event for a chunk that is not the last one. The default is unchanged, so a single call behaves exactly as before. The test drives a real session over a loopback socket with a minimal DAP client that completes the handshake and records the events it is sent, which is what makes "once per session, not once per chunk" observable at all. --- Project.toml | 2 +- src/packagedef.jl | 23 +++++++- test/test_debugsession.jl | 113 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 test/test_debugsession.jl 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/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 From 7e4e3631cf4d11a7bbf3e85ee5e346fd267ee48f Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Wed, 19 Aug 2026 10:50:39 -0700 Subject: [PATCH 2/3] Serialise CI test processes and document notify_termination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Julia 1.0 has no precompile cache locking, and on Windows a `.ji` that another process holds open cannot be replaced. This package had exactly one test item, so one test process started and nothing raced; adding the debug session tests makes several start together, all loading JuliaInterpreter, and whichever loses dies with "Cannot write cache file". Nothing in the package can fix that, so CI now runs the test items one at a time — they take seconds each, so it costs nothing. Requires julia-testitems/testitem-workflow#8, which forwards `max-workers` to `julia-run-testitems`; the action has always accepted it, the workflow just never passed one through. The README documented only the four-positional form of `debug_code`, and the README is what people actually read, so it now covers `notify_termination` too. --- .github/workflows/juliaci.yml | 6 ++++++ README.md | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/juliaci.yml b/.github/workflows/juliaci.yml index 6aa24b5..0ce3daa 100644 --- a/.github/workflows/juliaci.yml +++ b/.github/workflows/juliaci.yml @@ -12,6 +12,12 @@ jobs: with: include-all-compatible-minor-versions: true include-rc-versions: true + # Julia 1.0 has no precompile cache locking, and on Windows a `.ji` that another + # process holds open cannot be replaced — so as soon as this package has more than + # one test item, the test processes race to precompile JuliaInterpreter and whichever + # loses dies with "Cannot write cache file". The test items here run in seconds, so + # serialising them costs nothing. + max-workers: "1" permissions: write-all secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} 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) +``` From a088bdf28535cf075e91c13badf22dab03938656 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Wed, 19 Aug 2026 11:48:20 -0700 Subject: [PATCH 3/3] Drop the max-workers workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows + Julia 1.0 failure was not this package's to fix. TestItemControllers nominates one test process to activate the environment and holds the others back until it reports, so that the test environment is precompiled exactly once — but on Julia 1.0 to 1.8 activation built nothing, because those TestEnv variants stop at `Pkg.activate` and only 1.9 and later finish with `Pkg._auto_precompile`. Every process therefore reached its first `using` at the same time, with no cache file locking in Base before 1.10 to protect them. julia-testitems/TestItemControllers.jl#66 closes that, so capping the workers here is unnecessary. --- .github/workflows/juliaci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/juliaci.yml b/.github/workflows/juliaci.yml index 0ce3daa..6aa24b5 100644 --- a/.github/workflows/juliaci.yml +++ b/.github/workflows/juliaci.yml @@ -12,12 +12,6 @@ jobs: with: include-all-compatible-minor-versions: true include-rc-versions: true - # Julia 1.0 has no precompile cache locking, and on Windows a `.ji` that another - # process holds open cannot be replaced — so as soon as this package has more than - # one test item, the test processes race to precompile JuliaInterpreter and whichever - # loses dies with "Cannot write cache file". The test items here run in seconds, so - # serialising them costs nothing. - max-workers: "1" permissions: write-all secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }}