Skip to content

Conversation

@lionel-
Copy link
Contributor

@lionel- lionel- commented Feb 3, 2026

This PR adds comprehensive DAP (Debug Adapter Protocol) integration tests and extracts test infrastructure into a dedicated ark_test crate.

This addresses the "Planned: DAP protocol tests" section from #1003.
Closes #1026.

ark_test crate

Test utilities for integration tests have been extracted from ark::fixtures into a new ark_test crate. This provides:

  • DummyArkFrontend: Mock Jupyter frontend that communicates with the kernel over ZMQ sockets. Moved from ark::fixtures::dummy_frontend.

  • DapClient: A minimal DAP client for testing. Connects to the DAP server, sends requests, and receives events/responses. Handles the initialize/attach handshake and provides typed methods for common operations (stack_trace(), set_breakpoints(), recv_stopped(), etc.).

  • MessageAccumulator: Collects IOPub messages and coalesces stream fragments, making tests immune to batching variations. Stream messages from R can arrive as one message or split across multiple messages depending on timing. The accumulator automatically combines streams with the same parent header. This work was done to fix test flakiness with IOPub messages.

  • SourceFile: Helper for creating temporary R source files. Paired with DummyArkFrontend::source_file() and source_file_and_hit_breakpoint() to exercise the source() hook in tests.

  • Tracing infrastructure: Enable ARK_TEST_TRACE=1 (or =dap, =iopub) to see timestamped message flows during test runs. This is very helpful for agentic debugging of test failures or development of new tests.

Following this reorganisation, the ark::fixtures module now only contains utilities for internal unit tests (r_test_init, r_test_lock, point_from_cursor).

Message ordering limitations

Comm messages vs IOPub

The start_debug and stop_debug messages travel through the comm manager thread before reaching IOPub, while messages like idle are sent directly to IOPub. This means these debug lifecycle messages can arrive out of order relative to other IOPub messages. For example, idle might arrive before start_debug even though logically the debug session started first.

I think the architecture should be fixed and I'm working on this in parallel. In the meantime, the test infrastructure is designed to be resilient to this non-determinism.

recv_iopub_until and recv_iopub_async

For message flows where ordering is non-deterministic, tests use recv_iopub_until (low-level with full accumulator access) or recv_iopub_async (convenience wrapper for predicate lists). Both use MessageAccumulator internally, so stream coalescing works uniformly. They accumulate messages until a condition is met, rather than asserting strict sequential order.

// Instead of:
frontend.recv_iopub_start_debug();  // might fail if idle arrives first
frontend.recv_iopub_idle();

// Use:
frontend.recv_iopub_async(vec![
    is_start_debug(),
    is_idle(),
]);

The predicates specify what messages must arrive, not when. When ordering does matter locally, in_order() can be used as an escape hatch:

frontend.recv_iopub_until(|acc| {
    // execute_result must come before idle, but start_debug can be anywhere
    acc.has_comm_method("start_debug") &&
    acc.in_order(&[is_execute_result(), is_idle()])
});
Stream message coalescing

Stream messages (stdout/stderr) from R can be batched or split unpredictably. A single print() might produce one message or several, depending on timing. The MessageAccumulator automatically coalesces stream fragments with the same parent header, so tests check the final content rather than message boundaries:

// Instead of asserting exact stream messages:
frontend.recv_iopub_stream_stdout("debug at ...");  // fragile

// Use predicates that check coalesced content:
frontend.recv_iopub_async(vec![
    stream_contains("debug at"),
    is_idle(),
]);

This makes tests immune to batching variations while still verifying the expected output appears. The tradeoff is looser assertions: we check that expected content is present rather than asserting exact message sequences. Messages that don't match any predicate are silently drained rather than causing failures.

Bug fixes

URI normalization for symlinks

On macOS, /var/folders is a symlink to /private/var/folders. R's normalizePath() resolves symlinks, so source references from R use the canonical path. However, the frontend sends URIs with the non-canonical path.

ExtUrl::from_file_path() now canonicalizes paths before converting to URIs. This ensures breakpoint URIs match the source references R produces, fixing breakpoint identity mismatches on macOS.

This fix was necessary to reliably work with temp files in the test.

stop_debug event guard

Previously, Dap::stop_debugging() would send stop_debug even when not in a debug session, which emitted extra unneeded messages. Now it tracks was_debugging and only sends the event if we were actually debugging.

DAP test coverage

The new tests cover the scenarios outlined in #1003. These were agent-generated. Given the volume, I focused on making sure the test infrastructure allows producing (1) clear and readable tests, and (2) reliable non-flaky tests.

Basic DAP operations (dap.rs)
  • Initialize and disconnect
  • Stopped at browser(), stack frames for virtual documents
  • Nested stack frames (function call chains)
  • Recursive functions
  • Error during debug (stepping to error vs evaluating error expression)
  • Nested browser sessions (debugonce inside browser)
Breakpoint state management (dap_breakpoints.rs)
  • SetBreakpoints returns correct initial state (unverified)
  • Clearing breakpoints
  • State preservation on resubmit (same lines keep same IDs)
  • Disable/enable preserves verified state
  • Document hash changes reset breakpoint state
  • Breakpoints isolated per file
Breakpoint verification (dap_breakpoints_verification.rs)
  • Verification events sent when breakpoints become verified
  • Line adjustment reflected in events after verification
  • Multiple breakpoints verified together
  • Breakpoints added after parse don't get incorrectly verified
  • Error during source verifies preceding breakpoints only
Line adjustment (dap_breakpoints_line_adjustment.rs)
  • Breakpoints inside multiline expressions anchor to start
  • Breakpoints on blank lines/comments anchor to next statement
  • Multiple breakpoints anchoring to same line
Integration scenarios (dap_breakpoints_integrations.rs)
  • source(file, echo=TRUE) (used by Positron)
  • R6 class methods
  • Source hook fallback when disabled
  • Source hook fallback with unsupported arguments
  • Re-sourcing same file
Stepping (dap_breakpoints_stepping.rs, dap_step.rs)
  • Full breakpoint flow: source, verify, hit, hit again
  • Top-level {} blocks don't leave global env in debug mode
  • Inner breakpoints verified on step-over
  • Step next, step in, step out
  • Auto-stepping over injected expressions
Reconnection (dap_breakpoints_reconnect.rs)
  • Basic reconnection
  • Breakpoint state preserved across reconnect
  • Breakpoint state reset on reconnect after file change
Variables (dap_variables.rs)
  • Scopes request returns local/global
  • Variables in local scope
  • Nested object expansion (lists, environments)
  • Large vectors truncated appropriately

@lionel- lionel- force-pushed the task/dap-protocol-tests branch from 05d50cd to fb0c28b Compare February 3, 2026 18:56
@lionel-
Copy link
Contributor Author

lionel- commented Feb 3, 2026

@lionel- lionel- force-pushed the task/dap-protocol-tests branch from 617b31c to f48a415 Compare February 3, 2026 19:24
thread 'test_dap_error_during_debug' (11549) panicked at
crates/ark_test/src/dummy_frontend.rs:841:13:
    IOPub socket has 1 unexpected non-Stream message(s) on exit:
    [
        CommMsg(
            JupyterMessage {
                zmq_identities: [],
                header: JupyterHeader {
                    msg_id: "fb32aa47-2a8d-43d2-9c02-56e224db2ae8",
                    session: "20421a69-fba6-4ea2-82f4-681951d6889f",
                    username: "kernel",
                    date: "2026-02-03T19:00:56.723214957+00:00",
                    msg_type: "comm_msg",
                    version: "5.3",
                },
                parent_header: None,
                content: CommWireMsg {
                    comm_id: "40b79191-82d7-4c9b-8f09-d167885dde00",
                    data: Object {
                        "method": String("execute"),
                        "params": Object {
                            "command": String("Q"),
                        },
                    },
                },
            },
        ),
    ]
thread 'test_dap_breakpoint_lapply_iteration' (39284) panicked at
crates/ark/tests/dap_breakpoints_stepping.rs:498:14:
    assertion failed: `Stream(JupyterMessage { zmq_identities: [],
header: JupyterHeader { msg_id: "69a2afaa-1959-44a3-adca-1e395fb2c288",
session: "3d7f80b8-2a8b-40cb-abb2-388b1355f31c", username: "kernel",
date: "2026-02-03T19:29:07.985406+00:00", msg_type: "stream", version:
"5.3" }, parent_header: Some(JupyterHeader { msg_id:
"5a18b0b1-1398-4c34-bf6c-aa2f58519f68", session:
"87bfbe86-6a1a-4ac7-92a3-4a4e2a3282ec", username: "kernel", date:
"2026-02-03T19:29:07.984855+00:00", msg_type: "execute_request",
version: "5.3" }), content: StreamOutput { name: Stdout, text: "debug at
file:///private/var/folders/yz/zr09txvs5dn18vt4cn21kzl40000gn/T/.tmpsqOh5G#3:
y <- x + 1\n" } })` does not match `Message::ExecuteInput(data)`
    note: run with `RUST_BACKTRACE=1` environment variable to display a
backtrace
@lionel- lionel- force-pushed the task/dap-protocol-tests branch from c9cf78b to bf64438 Compare February 3, 2026 21:01
On both macOS runners, maybe because of added debug eprintln:

   thread 'test_dap_step_to_adjacent_breakpoint' (62837) panicked at
crates/ark/tests/dap_breakpoints_stepping.rs:315:14:
    assertion failed: `Stream(JupyterMessage { zmq_identities: [],
header: JupyterHeader { msg_id: "bfd7baf8-e601-4f44-b1af-bc4a06b93066",
session: "1f3ee245-4529-4ae7-aacd-1fdc664f6023", username: "kernel",
date: "2026-02-03T21:29:53.363264+00:00", msg_type: "stream", version:
"5.3" }, parent_header: Some(JupyterHeader { msg_id:
"54bf8f63-5454-48b3-9428-8fe31433a06f", session:
"75f83fd3-101e-4e54-8328-88463a305493", username: "kernel", date:
"2026-02-03T21:29:53.362637+00:00", msg_type: "execute_request",
version: "5.3" }), content: StreamOutput { name: Stdout, text: "x <-
1\n" } })` does not match `Message::ExecuteInput(data)`
    note: run with `RUST_BACKTRACE=1` environment variable to display a
backtrace
    DAP trace: sending done signal to events thread
    DAP trace: done signal sent
@lionel- lionel- force-pushed the task/dap-protocol-tests branch from 0e0f479 to 9b3828b Compare February 3, 2026 22:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DAP protocol tests

2 participants