Skip to main content

perl_dap/debug_adapter/
mod.rs

1//! Debug Adapter Protocol (DAP) implementation for Perl debugging
2//!
3//! This module provides a DAP server that integrates with Perl's built-in debugger
4//! to enable debugging support in VSCode and other DAP-compatible editors.
5
6mod breakpoints;
7mod data_breakpoints;
8mod evaluation;
9mod execution;
10mod frames;
11mod output;
12mod patterns;
13mod process;
14mod variables;
15
16mod dispatch;
17mod parsing;
18mod regexes;
19pub(crate) mod safe_eval;
20mod session;
21mod sync_utils;
22mod transport;
23pub mod var_ref;
24mod variable_cache;
25
26use crate::breakpoint::{AstBreakpointValidator, BreakpointValidator};
27use crate::eval::SafeEvaluator;
28use crate::feature_catalog::has_feature as catalog_has_feature;
29use crate::inline_values::{collect_inline_values_with_runtime, extract_variable_names};
30use crate::protocol::{
31    BreakpointLocation, BreakpointLocationsArguments, BreakpointLocationsResponseBody,
32    CompletionItem, CompletionsArguments, CompletionsResponseBody, ContinueArguments,
33    ContinueResponseBody, DataBreakpointInfoArguments, DataBreakpointInfoResponseBody,
34    DisconnectArguments, EvaluateArguments, EvaluateResponseBody, ExceptionDetails,
35    ExceptionInfoArguments, ExceptionInfoResponseBody, GotoArguments, GotoTarget,
36    GotoTargetsArguments, GotoTargetsResponseBody, InlineValuesArguments, InlineValuesResponseBody,
37    LoadedSourcesResponseBody, Module, ModulesArguments, ModulesResponseBody, NextArguments,
38    PauseArguments, RestartArguments, Scope, ScopesArguments, ScopesResponseBody,
39    SetDataBreakpointsArguments, SetDataBreakpointsResponseBody, SetExceptionBreakpointsArguments,
40    SetExpressionArguments, SetExpressionResponseBody, SetFunctionBreakpointsArguments,
41    SetVariableArguments, SetVariableResponseBody, SourceArguments, SourceResponseBody,
42    StackTraceArguments, StepInArguments, StepInTarget, StepInTargetsArguments,
43    StepInTargetsResponseBody, StepOutArguments, TerminateArguments, VariablesArguments,
44};
45use crate::stack::{PerlStackParser, is_internal_frame_name_and_path};
46use crate::tcp_attach::{DapEvent, TcpAttachConfig, TcpAttachSession};
47use crate::types::{Source, StackFrame, Variable};
48use crate::variables::{PerlVariableRenderer, RenderedVariable, VariableParser, VariableRenderer};
49use perl_lexer::DAP_COMPLETION_KEYWORDS;
50use perl_lsp_rs_core::transport::framing::ContentLengthFramer;
51use perl_module::path::module_path_to_name;
52use serde::{Deserialize, Serialize};
53use serde_json::{Value, json};
54use std::collections::{HashMap, HashSet};
55use std::io::{self, BufRead, BufReader, Read, Write};
56use std::net::TcpListener;
57use std::path::{Path, PathBuf};
58use std::process::{Child, Stdio};
59use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
60use std::sync::mpsc::{Sender, channel};
61use std::sync::{Arc, Mutex};
62use std::thread;
63use std::time::{Duration, Instant};
64
65use crate::breakpoints::{BreakpointHitOutcome, BreakpointStore};
66use crate::debug_adapter::data_breakpoints::DataBreakpointRecord;
67use crate::debug_adapter::session::{DebugSession, DebugState, ResumeMode};
68use crate::debug_adapter::variable_cache::{VariableCache, VariableCacheKind, slice_variables};
69use crate::security;
70#[cfg(unix)]
71use nix::sys::signal::{self, Signal};
72#[cfg(unix)]
73use nix::unistd::Pid;
74use patterns::*;
75use safe_eval::validate_safe_expression;
76use sync_utils::{emit_event_safe, lock_or_recover};
77
78/// Check if the match is an escape sequence (preceded by backslash)
79fn is_escape_sequence(s: &str, match_start: usize) -> bool {
80    if match_start == 0 {
81        return false;
82    }
83    s.as_bytes()[match_start - 1] == b'\\'
84}
85
86/// DAP server that handles debug sessions
87pub struct DebugAdapter {
88    /// Sequence number for messages
89    seq: Arc<Mutex<i64>>,
90    /// Active debug session (process-based)
91    session: Arc<Mutex<Option<DebugSession>>>,
92    /// Attached process ID for PID-based attach mode
93    attached_pid: Arc<Mutex<Option<u32>>>,
94    /// TCP attach session (for connecting to running debugger)
95    tcp_session: Arc<Mutex<Option<TcpAttachSession>>>,
96    /// Breakpoints store
97    breakpoints: BreakpointStore,
98    /// Thread ID counter
99    thread_counter: Arc<Mutex<i32>>,
100    /// Output channel for sending events to client
101    event_sender: Option<Sender<DapMessage>>,
102    /// Bounded history of debugger output for stack/variable/evaluate parsing
103    recent_output: Arc<Mutex<RecentOutputBuffer>>,
104    /// Function breakpoints (`setFunctionBreakpoints`) stored with REPLACE semantics
105    function_breakpoints: Arc<Mutex<Vec<String>>>,
106    /// Monotonic IDs for function breakpoints
107    next_function_breakpoint_id: Arc<Mutex<i64>>,
108    /// Exception breakpoint policy: break on `die`/uncaught exception output.
109    exception_break_on_die: Arc<Mutex<bool>>,
110    /// Exception breakpoint policy: break on `warn`/carp/cluck output.
111    exception_break_on_warn: Arc<Mutex<bool>>,
112    /// Unique marker IDs used to frame debugger output per command.
113    debugger_output_marker: Arc<AtomicU64>,
114    /// Cancellation flag for in-progress requests.
115    cancel_requested: Arc<AtomicBool>,
116    /// Data breakpoints (watchpoints) stored with REPLACE semantics
117    data_breakpoints: Arc<Mutex<Vec<DataBreakpointRecord>>>,
118    /// Last exception message captured by the output reader (for exceptionInfo)
119    last_exception_message: Arc<Mutex<Option<String>>>,
120    /// Stored launch arguments for restart support
121    last_launch_args: Arc<Mutex<Option<Value>>>,
122    /// Goto target ID → (file_path, line) mapping for cross-file goto
123    goto_targets: Arc<Mutex<HashMap<i64, (String, i64)>>>,
124    /// Monotonic goto target ID counter
125    next_goto_target_id: Arc<Mutex<i64>>,
126    /// Workspace root for path validation (set during launch)
127    workspace_root: Arc<Mutex<Option<PathBuf>>>,
128    /// Transport broken flag: set by event handler on persistent write failure
129    transport_broken: Arc<AtomicBool>,
130    /// Tracks whether initialize request has been received (state machine validation)
131    initialized: Arc<AtomicBool>,
132}
133
134/// Represents a DAP message, which can be a request, response, or event.
135#[derive(Debug, Serialize, Deserialize)]
136#[serde(tag = "type")]
137pub enum DapMessage {
138    /// A request from the client to the debug adapter.
139    #[serde(rename = "request")]
140    Request {
141        /// Sequence number of the request.
142        seq: i64,
143        /// The command to execute.
144        command: String,
145        /// Arguments for the command.
146        arguments: Option<Value>,
147    },
148    /// A response from the debug adapter to a client request.
149    #[serde(rename = "response")]
150    Response {
151        /// Sequence number of the response.
152        seq: i64,
153        /// Sequence number of the corresponding request.
154        request_seq: i64,
155        /// Indicates whether the request was successful.
156        success: bool,
157        /// The command that was executed.
158        command: String,
159        /// The body of the response.
160        #[serde(skip_serializing_if = "Option::is_none")]
161        body: Option<Value>,
162        /// An optional message providing additional information.
163        #[serde(skip_serializing_if = "Option::is_none")]
164        message: Option<String>,
165    },
166    /// An event from the debug adapter to the client.
167    #[serde(rename = "event")]
168    Event {
169        /// Sequence number of the event.
170        seq: i64,
171        /// The type of event.
172        event: String,
173        /// The body of the event.
174        #[serde(skip_serializing_if = "Option::is_none")]
175        body: Option<Value>,
176    },
177}
178
179impl Default for DebugAdapter {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl DebugAdapter {
186    /// Create a new debug adapter
187    pub fn new() -> Self {
188        Self {
189            seq: Arc::new(Mutex::new(0)),
190            session: Arc::new(Mutex::new(None)),
191            attached_pid: Arc::new(Mutex::new(None)),
192            tcp_session: Arc::new(Mutex::new(None)),
193            breakpoints: BreakpointStore::new(),
194            thread_counter: Arc::new(Mutex::new(0)),
195            event_sender: None,
196            recent_output: Arc::new(Mutex::new(RecentOutputBuffer::new())),
197            function_breakpoints: Arc::new(Mutex::new(Vec::new())),
198            next_function_breakpoint_id: Arc::new(Mutex::new(1)),
199            exception_break_on_die: Arc::new(Mutex::new(false)),
200            exception_break_on_warn: Arc::new(Mutex::new(false)),
201            debugger_output_marker: Arc::new(AtomicU64::new(1)),
202            cancel_requested: Arc::new(AtomicBool::new(false)),
203            data_breakpoints: Arc::new(Mutex::new(Vec::new())),
204            last_exception_message: Arc::new(Mutex::new(None)),
205            last_launch_args: Arc::new(Mutex::new(None)),
206            goto_targets: Arc::new(Mutex::new(HashMap::new())),
207            next_goto_target_id: Arc::new(Mutex::new(1)),
208            workspace_root: Arc::new(Mutex::new(None)),
209            transport_broken: Arc::new(AtomicBool::new(false)),
210            initialized: Arc::new(AtomicBool::new(false)),
211        }
212    }
213
214    /// Set the event sender (primarily for testing)
215    pub fn set_event_sender(&mut self, sender: Sender<DapMessage>) {
216        self.event_sender = Some(sender);
217    }
218
219    /// Validate a client-provided source path against the workspace root.
220    ///
221    /// Returns the validated `PathBuf` on success, or an error message on failure.
222    /// If no workspace root is set (pre-launch), the path is allowed through with a
223    /// warning — defense-in-depth only blocks when a workspace boundary is known.
224    fn validate_source_path(&self, path: &str) -> Result<PathBuf, String> {
225        let ws = lock_or_recover(&self.workspace_root, "debug_adapter.workspace_root");
226        match ws.as_ref() {
227            Some(root) => security::validate_path(Path::new(path), root)
228                .map_err(|e| format!("Path validation failed: {e}")),
229            None => {
230                // No workspace set (pre-launch) — allow reads but accept the risk
231                Ok(PathBuf::from(path))
232            }
233        }
234    }
235
236    /// Get next sequence number (monotonically increasing, poison-safe)
237    fn next_seq(&self) -> i64 {
238        let mut seq = lock_or_recover(&self.seq, "next_seq");
239        *seq += 1;
240        *seq
241    }
242
243    /// Send an event to the client
244    fn send_event(&self, event: &str, body: Option<Value>) {
245        if let Some(ref sender) = self.event_sender {
246            let seq = self.next_seq();
247            let msg = DapMessage::Event { seq, event: event.to_string(), body };
248            let _ = sender.send(msg);
249        }
250    }
251
252    /// Snapshot debugger output history for parsing without holding locks.
253    fn snapshot_recent_output_lines(&self) -> Vec<String> {
254        let output = lock_or_recover(&self.recent_output, "debug_adapter.recent_output");
255        output.lines.iter().map(|line| line.raw.clone()).collect()
256    }
257
258    fn append_recent_output_line_locked(output: &mut RecentOutputBuffer, line: &str) {
259        if output.lines.len() >= RECENT_OUTPUT_MAX_LINES {
260            let _ = output.lines.pop_front();
261        }
262
263        let id = output.next_line_id;
264        output.next_line_id = output.next_line_id.saturating_add(1);
265        output.lines.push_back(RecentOutputLine {
266            id,
267            raw: line.to_string(),
268            normalized: Self::normalize_debugger_output_line(line),
269        });
270    }
271
272    /// Allocate a unique marker id used for framed debugger output capture.
273    fn next_debugger_marker_id(&self) -> u64 {
274        self.debugger_output_marker.fetch_add(1, Ordering::Relaxed)
275    }
276
277    /// Write a debugger command and flush immediately so output framing remains ordered.
278    fn write_debugger_command(stdin: &mut impl Write, command: &str) -> Result<(), String> {
279        stdin.write_all(command.as_bytes()).map_err(|e| format!("write debugger command: {e}"))?;
280        stdin.flush().map_err(|e| format!("flush debugger command: {e}"))?;
281        Ok(())
282    }
283
284    /// Send commands wrapped with unique begin/end markers.
285    ///
286    /// Returns `(begin_marker, end_marker)` so callers can wait for framed output.
287    fn send_framed_debugger_commands(
288        &self,
289        stdin: &mut impl Write,
290        commands: &[String],
291    ) -> Result<(String, String), String> {
292        let marker_id = self.next_debugger_marker_id();
293        let begin_marker = format!("DAP_BEGIN_{marker_id}");
294        let end_marker = format!("DAP_END_{marker_id}");
295
296        Self::write_debugger_command(stdin, &format!("p \"{begin_marker}\"\n"))?;
297        for command in commands {
298            if command.ends_with('\n') {
299                Self::write_debugger_command(stdin, command)?;
300            } else {
301                Self::write_debugger_command(stdin, &format!("{command}\n"))?;
302            }
303        }
304        Self::write_debugger_command(stdin, &format!("p \"{end_marker}\"\n"))?;
305
306        Ok((begin_marker, end_marker))
307    }
308
309    /// Capture debugger output lines between begin/end markers.
310    fn capture_framed_debugger_output(
311        &self,
312        begin_marker: &str,
313        end_marker: &str,
314        timeout_ms: u64,
315    ) -> Option<Vec<String>> {
316        let deadline =
317            Instant::now() + Duration::from_millis(Self::debugger_timeout_budget_ms(timeout_ms));
318        let mut next_scan_id = 0_u64;
319        let mut saw_begin_marker = false;
320        let mut framed_lines = Vec::new();
321
322        loop {
323            // Check for cancellation before each poll iteration
324            if self.cancel_requested.load(Ordering::Acquire) {
325                self.cancel_requested.store(false, Ordering::Release);
326                return None;
327            }
328
329            {
330                let output = lock_or_recover(&self.recent_output, "debug_adapter.recent_output");
331                for line in output.lines.iter().filter(|line| line.id >= next_scan_id) {
332                    if !saw_begin_marker {
333                        if Self::line_contains_full_marker(&line.normalized, begin_marker) {
334                            saw_begin_marker = true;
335                            framed_lines.clear();
336                        }
337                    } else if Self::line_contains_full_marker(&line.normalized, end_marker) {
338                        return Some(framed_lines);
339                    } else if !line.normalized.trim().is_empty() {
340                        framed_lines.push(line.normalized.clone());
341                    }
342                }
343
344                if let Some(last) = output.lines.back() {
345                    next_scan_id = last.id.saturating_add(1);
346                }
347            }
348
349            if Instant::now() >= deadline {
350                return None;
351            }
352
353            thread::sleep(Duration::from_millis(DEBUGGER_FRAME_POLL_MS));
354        }
355    }
356
357    /// Wait briefly for debugger command responses to arrive in the output buffer.
358    fn debugger_output_window_ms(timeout_ms: u32) -> u64 {
359        u64::from(timeout_ms).max(DEBUGGER_QUERY_WAIT_MS)
360    }
361
362    fn wait_for_debugger_output_window(timeout_ms: u32) {
363        thread::sleep(Duration::from_millis(Self::debugger_output_window_ms(timeout_ms)));
364    }
365
366    /// Expand debugger query budgets in heavily instrumented environments.
367    ///
368    /// `cargo llvm-cov` adds noticeable overhead to framed debugger queries against a
369    /// real `perl -d` subprocess. Keep the normal fast path unchanged, but allow a
370    /// larger budget when coverage profiling is active.
371    fn debugger_timeout_budget_ms(timeout_ms: u64) -> u64 {
372        let base = timeout_ms.max(DEBUGGER_QUERY_WAIT_MS);
373        if std::env::var_os("LLVM_PROFILE_FILE").is_some()
374            || std::env::var_os("CARGO_LLVM_COV").is_some()
375        {
376            base.clamp(15_000, 30_000)
377        } else {
378            base
379        }
380    }
381
382    /// Convert i64 values in protocol payloads to i32 with saturation.
383    fn i64_to_i32_saturating(value: i64) -> i32 {
384        match i32::try_from(value) {
385            Ok(v) => v,
386            Err(_) => {
387                if value.is_negative() {
388                    i32::MIN
389                } else {
390                    i32::MAX
391                }
392            }
393        }
394    }
395
396    fn line_contains_full_marker(line: &str, marker: &str) -> bool {
397        line.match_indices(marker).any(|(idx, _)| {
398            let before = line[..idx].chars().next_back();
399            let after = line[idx + marker.len()..].chars().next();
400            let before_ok =
401                before.is_none_or(|ch| !matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_'));
402            let after_ok =
403                after.is_none_or(|ch| !matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_'));
404            before_ok && after_ok
405        })
406    }
407
408    /// Push a line into the recent-output buffer for testing parser paths.
409    ///
410    /// Only for use in tests; not part of the public API contract.
411    #[cfg(any(test, feature = "test-helpers"))]
412    pub fn push_recent_output_line_for_test(&self, line: &str) {
413        let mut output = lock_or_recover(&self.recent_output, "debug_adapter.push_recent_output");
414        Self::append_recent_output_line_locked(&mut output, line);
415    }
416
417    /// Seed a minimal DebugSession in Running state for testing stale-ref guards.
418    ///
419    /// Creates a `perl -e 1` child process, installs it as the active session, and
420    /// sets the state to `DebugState::Running` so that stale-ref-guard tests can
421    /// verify the "session is not stopped" path without a live debugging scenario.
422    ///
423    /// Only for use in tests; not part of the public API contract.
424    #[cfg(any(test, feature = "test-helpers"))]
425    pub fn seed_running_session_for_test(&self) {
426        use crate::debug_adapter::session::{DebugSession, DebugState, ResumeMode};
427        use crate::debug_adapter::variable_cache::VariableCache;
428        if let Ok(child) = std::process::Command::new("perl")
429            .arg("-e")
430            .arg("1")
431            .stdin(std::process::Stdio::piped())
432            .stdout(std::process::Stdio::piped())
433            .stderr(std::process::Stdio::piped())
434            .spawn()
435        {
436            if let Ok(mut guard) = self.session.lock() {
437                *guard = Some(DebugSession {
438                    process: child,
439                    state: DebugState::Running,
440                    stack_frames: vec![],
441                    variable_cache: VariableCache::default(),
442                    thread_id: 1,
443                    last_resume_mode: ResumeMode::Continue,
444                });
445            }
446        }
447    }
448
449    /// Seed `attached_pid` with the given PID for testing.
450    ///
451    /// Use a PID that is guaranteed not to exist (e.g. `999_999`) to drive the
452    /// "session present, signal delivery failed" path in `handle_pause`.
453    ///
454    /// Only for use in tests; not part of the public API contract.
455    #[cfg(any(test, feature = "test-helpers"))]
456    pub fn seed_attached_pid_for_test(&self, pid: u32) {
457        if let Ok(mut guard) = self.attached_pid.lock() {
458            *guard = Some(pid);
459        }
460    }
461
462    #[cfg(test)]
463    fn seed_session_for_test(&self) {
464        // Spawn a cheap no-op subprocess so we have a real Child (no unsafe zeroed memory).
465        let child = Self::spawn_noop_child_for_test();
466        let mut session = lock_or_recover(&self.session, "debug_adapter.seed_session");
467        *session = Some(DebugSession {
468            process: child,
469            state: DebugState::Stopped,
470            stack_frames: Vec::new(),
471            variable_cache: VariableCache::default(),
472            thread_id: 1,
473            last_resume_mode: ResumeMode::Unknown,
474        });
475    }
476
477    /// Spawn the cheapest available no-op child process for use in unit tests.
478    /// Tries perl first, then a platform-native no-op.  The test panics if no
479    /// subprocess can be spawned at all — that indicates a broken CI environment.
480    #[cfg(test)]
481    fn spawn_noop_child_for_test() -> std::process::Child {
482        use std::process::{Command, Stdio};
483        // perl -e 1 exits immediately with no output.
484        if let Ok(c) = Command::new("perl")
485            .arg("-e")
486            .arg("1")
487            .stdin(Stdio::piped())
488            .stdout(Stdio::piped())
489            .stderr(Stdio::piped())
490            .spawn()
491        {
492            return c;
493        }
494        // Platform-native fallback when perl is not on PATH.
495        #[cfg(windows)]
496        let (prog, args): (&str, &[&str]) = ("cmd", &["/c", "exit", "0"]);
497        #[cfg(not(windows))]
498        let (prog, args): (&str, &[&str]) = ("true", &[]);
499        // SAFETY NOTE: no unsafe — uses only std::process::Command.
500        // The panic here is intentional: if *neither* perl nor the OS no-op
501        // binary is available the test environment is fundamentally broken and
502        // proceeding would produce meaningless results.
503        Command::new(prog)
504            .args(args)
505            .stdin(Stdio::piped())
506            .stdout(Stdio::piped())
507            .stderr(Stdio::piped())
508            .spawn()
509            .unwrap_or_else(|e| {
510                panic!("seed_session_for_test: cannot spawn any noop subprocess ({prog}): {e}")
511            })
512    }
513
514    #[cfg(test)]
515    fn inject_stack_frames_for_test(&self, frames: Vec<StackFrame>) {
516        let mut session = lock_or_recover(&self.session, "debug_adapter.inject_frames");
517        if let Some(ref mut sess) = *session {
518            sess.stack_frames = frames;
519        }
520    }
521
522    #[cfg(test)]
523    fn stack_frames_snapshot_for_test(&self) -> Vec<StackFrame> {
524        let session = lock_or_recover(&self.session, "debug_adapter.snapshot_frames");
525        session.as_ref().map(|s| s.stack_frames.clone()).unwrap_or_default()
526    }
527
528    /// Seed the active session's variable_cache with an EvalResult entry for testing.
529    ///
530    /// Used by the fix #1338 guard test: a CACHED EvalResult ref must serve its children
531    /// via the cache-hit path, NOT be swallowed by the early-return short-circuit added
532    /// for stale (cache-miss) EvalResult refs.
533    ///
534    /// Only for use in tests; not part of the public API contract.
535    #[cfg(test)]
536    pub fn seed_eval_result_cache_for_test(
537        &self,
538        eval_ref_wire: i32,
539        variables: Vec<crate::types::Variable>,
540    ) {
541        let mut session = lock_or_recover(&self.session, "debug_adapter.seed_eval_result_cache");
542        if let Some(ref mut sess) = *session {
543            sess.variable_cache.upsert(eval_ref_wire, VariableCacheKind::EvaluateResult, variables);
544        }
545    }
546
547    /// Seed a stopped DebugSession with a given set of stack frames for testing
548    /// frameId validation paths in handle_evaluate.
549    ///
550    /// Only for use in tests; not part of the public API contract.
551    #[cfg(any(test, feature = "test-helpers"))]
552    pub fn seed_stopped_session_with_frames_for_test(&self, frames: Vec<crate::types::StackFrame>) {
553        use std::process::{Command, Stdio};
554        let Ok(child) = Command::new("perl")
555            .arg("-e")
556            .arg("1")
557            .stdin(Stdio::piped())
558            .stdout(Stdio::piped())
559            .stderr(Stdio::piped())
560            .spawn()
561        else {
562            return;
563        };
564        let mut session = lock_or_recover(&self.session, "debug_adapter.seed_stopped_session");
565        *session = Some(DebugSession {
566            process: child,
567            state: DebugState::Stopped,
568            stack_frames: frames,
569            variable_cache: VariableCache::default(),
570            thread_id: 1,
571            last_resume_mode: ResumeMode::Unknown,
572        });
573    }
574}
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    fn create_breakpoint_test_perl_file()
580    -> Result<(tempfile::NamedTempFile, String), Box<dyn std::error::Error>> {
581        let mut file = tempfile::NamedTempFile::with_suffix(".pl")?;
582        let perl_code = r#"#!/usr/bin/perl
583use strict;
584use warnings;
585
586my $x = 1;
587my $y = 2;
588my $z = $x + $y;
589
590if ($x > 0) {
591    print "positive\n";
592}
593
594my @arr = (1, 2, 3);
595while (my $item = shift @arr) {
596    my $doubled = $item * 2;
597    print "$doubled\n";
598}
599
600sub process {
601    my ($value) = @_;
602    my $result = $value * 2;
603    return $result;
604}
605
606print "done\n";
607my $final = process($x);
608print "result: $final\n";
609"#;
610        file.write_all(perl_code.as_bytes())?;
611        file.flush()?;
612        let path = file.path().to_string_lossy().to_string();
613        Ok((file, path))
614    }
615
616    #[test]
617    fn test_debug_adapter_creation() {
618        let adapter = DebugAdapter::new();
619        assert!(adapter.session.lock().ok().is_some_and(|guard| guard.is_none()));
620        assert!(adapter.breakpoints.is_empty());
621    }
622
623    #[test]
624    fn test_sequence_numbers() {
625        let adapter = DebugAdapter::new();
626        assert_eq!(adapter.next_seq(), 1);
627        assert_eq!(adapter.next_seq(), 2);
628        assert_eq!(adapter.next_seq(), 3);
629    }
630
631    #[test]
632    fn test_debugger_output_window_ms_enforces_minimum_budget() {
633        assert_eq!(DebugAdapter::debugger_output_window_ms(1), DEBUGGER_QUERY_WAIT_MS);
634    }
635
636    #[test]
637    fn test_debugger_output_window_ms_honors_extended_budget() {
638        assert_eq!(DebugAdapter::debugger_output_window_ms(600), 600);
639    }
640
641    #[test]
642    fn test_initialize_response() -> Result<(), Box<dyn std::error::Error>> {
643        let mut adapter = DebugAdapter::new();
644        let response = adapter.handle_request(1, "initialize", None);
645
646        match response {
647            DapMessage::Response { success, command, body, .. } => {
648                assert!(success);
649                assert_eq!(command, "initialize");
650                assert!(body.is_some());
651            }
652            _ => return Err("Expected response".into()),
653        }
654        Ok(())
655    }
656
657    #[test]
658    fn test_set_breakpoints_replace_semantics_updates_adapter_store()
659    -> Result<(), Box<dyn std::error::Error>> {
660        let (_keep, source_path) = create_breakpoint_test_perl_file()?;
661        let mut adapter = DebugAdapter::new();
662
663        let first = adapter.handle_request(
664            1,
665            "setBreakpoints",
666            Some(json!({
667                "source": { "path": source_path },
668                "breakpoints": [{ "line": 10 }],
669            })),
670        );
671        match first {
672            DapMessage::Response { success: true, command, .. } => {
673                assert_eq!(command, "setBreakpoints");
674            }
675            other => {
676                return Err(format!("expected successful setBreakpoints, got {other:?}").into());
677            }
678        }
679
680        let first_lines: Vec<i64> =
681            adapter.breakpoints.get_breakpoints(&source_path).iter().map(|bp| bp.line).collect();
682        assert_eq!(first_lines, vec![10], "first request should seed one stored breakpoint");
683
684        let second = adapter.handle_request(
685            2,
686            "setBreakpoints",
687            Some(json!({
688                "source": { "path": source_path },
689                "breakpoints": [
690                    { "line": 20 },
691                    { "line": 26 },
692                ],
693            })),
694        );
695        match second {
696            DapMessage::Response { success: true, command, .. } => {
697                assert_eq!(command, "setBreakpoints");
698            }
699            other => {
700                return Err(format!("expected successful setBreakpoints, got {other:?}").into());
701            }
702        }
703
704        let stored_lines: Vec<i64> =
705            adapter.breakpoints.get_breakpoints(&source_path).iter().map(|bp| bp.line).collect();
706        assert_eq!(
707            stored_lines,
708            vec![20, 26],
709            "second request must replace the stored adapter breakpoints, not append to them"
710        );
711
712        Ok(())
713    }
714
715    #[test]
716    fn test_initialize_capabilities_follow_feature_catalog()
717    -> Result<(), Box<dyn std::error::Error>> {
718        let mut adapter = DebugAdapter::new();
719        let init = adapter.handle_request(1, "initialize", None);
720
721        let capabilities = match init {
722            DapMessage::Response { success: true, command, body: Some(body), .. }
723                if command == "initialize" =>
724            {
725                body
726            }
727            _ => return Err("Expected successful initialize response".into()),
728        };
729
730        let capability_map =
731            capabilities.as_object().ok_or("Initialize response body must be a JSON object")?;
732
733        let expectations = [
734            ("supportsConfigurationDoneRequest", crate::feature_catalog::has_feature("dap.core")),
735            ("supportsFunctionBreakpoints", crate::feature_catalog::has_feature("dap.core")),
736            (
737                "supportsConditionalBreakpoints",
738                crate::feature_catalog::has_feature("dap.breakpoints.basic"),
739            ),
740            (
741                "supportsHitConditionalBreakpoints",
742                crate::feature_catalog::has_feature("dap.breakpoints.hit_condition"),
743            ),
744            ("supportsEvaluateForHovers", crate::feature_catalog::has_feature("dap.core")),
745            ("supportsSetVariable", crate::feature_catalog::has_feature("dap.core")),
746            ("supportsValueFormattingOptions", crate::feature_catalog::has_feature("dap.core")),
747            ("supportTerminateDebuggee", crate::feature_catalog::has_feature("dap.core")),
748            ("supportsLogPoints", crate::feature_catalog::has_feature("dap.breakpoints.logpoints")),
749            (
750                "supportsExceptionOptions",
751                crate::feature_catalog::has_feature("dap.exceptions.die")
752                    || crate::feature_catalog::has_feature("dap.exceptions.warn"),
753            ),
754            (
755                "supportsExceptionFilterOptions",
756                crate::feature_catalog::has_feature("dap.exceptions.die")
757                    || crate::feature_catalog::has_feature("dap.exceptions.warn"),
758            ),
759            ("supportsInlineValues", crate::feature_catalog::has_feature("dap.inline_values")),
760            ("supportsTerminateRequest", crate::feature_catalog::has_feature("dap.core")),
761            ("supportsCompletionsRequest", crate::feature_catalog::has_feature("dap.completions")),
762            ("supportsModulesRequest", crate::feature_catalog::has_feature("dap.modules")),
763            ("supportsDataBreakpoints", crate::feature_catalog::has_feature("dap.watchpoints")),
764            (
765                "supportsTerminateThreadsRequest",
766                crate::feature_catalog::has_feature("dap.terminate_threads"),
767            ),
768            ("supportsGotoTargetsRequest", crate::feature_catalog::has_feature("dap.core")),
769            ("supportsRestartFrame", crate::feature_catalog::has_feature("dap.restart_frame")),
770            (
771                "supportsStepInTargetsRequest",
772                crate::feature_catalog::has_feature("dap.step_in_targets"),
773            ),
774        ];
775
776        for (capability, expected) in expectations {
777            let actual = capability_map
778                .get(capability)
779                .and_then(Value::as_bool)
780                .ok_or_else(|| format!("Capability `{capability}` must be present as boolean"))?;
781            assert_eq!(
782                actual, expected,
783                "Capability `{capability}` must mirror features.toml advertisement"
784            );
785        }
786
787        let exception_filters = capability_map
788            .get("exceptionBreakpointFilters")
789            .and_then(Value::as_array)
790            .ok_or("exceptionBreakpointFilters must be present as an array")?;
791
792        let has_filter = |id: &str| -> bool {
793            exception_filters.iter().any(|f| f.get("filter").and_then(Value::as_str) == Some(id))
794        };
795
796        let die_enabled = crate::feature_catalog::has_feature("dap.exceptions.die");
797        let warn_enabled = crate::feature_catalog::has_feature("dap.exceptions.warn");
798
799        assert_eq!(
800            has_filter("die"),
801            die_enabled,
802            "die filter presence must match dap.exceptions.die"
803        );
804        assert_eq!(
805            has_filter("all"),
806            die_enabled,
807            "all filter presence must match dap.exceptions.die"
808        );
809        assert_eq!(
810            has_filter("warn"),
811            warn_enabled,
812            "warn filter presence must match dap.exceptions.warn"
813        );
814
815        if !die_enabled && !warn_enabled {
816            assert!(
817                exception_filters.is_empty(),
818                "exceptionBreakpointFilters must be empty when no exception features are enabled"
819            );
820        }
821
822        Ok(())
823    }
824
825    #[test]
826    fn test_initialize_capabilities_are_backed_by_handlers()
827    -> Result<(), Box<dyn std::error::Error>> {
828        let mut adapter = DebugAdapter::new();
829        let init = adapter.handle_request(1, "initialize", None);
830
831        let capabilities = match init {
832            DapMessage::Response { success: true, command, body: Some(body), .. }
833                if command == "initialize" =>
834            {
835                body
836            }
837            _ => return Err("Expected successful initialize response".into()),
838        };
839
840        let capability_map =
841            capabilities.as_object().ok_or("Initialize response body must be a JSON object")?;
842
843        let capability_to_command = [
844            ("supportsConfigurationDoneRequest", "configurationDone"),
845            ("supportsFunctionBreakpoints", "setFunctionBreakpoints"),
846            ("supportsConditionalBreakpoints", "setBreakpoints"),
847            ("supportsHitConditionalBreakpoints", "setBreakpoints"),
848            ("supportsEvaluateForHovers", "evaluate"),
849            ("supportsSetVariable", "setVariable"),
850            ("supportsValueFormattingOptions", "variables"),
851            ("supportsLogPoints", "setBreakpoints"),
852            ("supportsExceptionOptions", "setExceptionBreakpoints"),
853            ("supportsExceptionFilterOptions", "setExceptionBreakpoints"),
854            ("supportsInlineValues", "inlineValues"),
855            ("supportsTerminateRequest", "terminate"),
856            ("supportTerminateDebuggee", "terminate"),
857            ("supportsCompletionsRequest", "completions"),
858            ("supportsModulesRequest", "modules"),
859            ("supportsRestartRequest", "restart"),
860            ("supportsExceptionInfoRequest", "exceptionInfo"),
861            ("supportsBreakpointLocationsRequest", "breakpointLocations"),
862            ("supportsSetExpression", "setExpression"),
863            ("supportsDataBreakpoints", "setDataBreakpoints"),
864            ("supportsLoadedSourcesRequest", "loadedSources"),
865            ("supportsCancelRequest", "cancel"),
866            ("supportsRestartFrame", "restartFrame"),
867            ("supportsStepInTargetsRequest", "stepInTargets"),
868            ("supportsGotoTargetsRequest", "gotoTargets"),
869            ("supportsTerminateThreadsRequest", "terminateThreads"),
870        ];
871
872        let mut mapped_commands = HashSet::new();
873        for (capability, raw_value) in capability_map {
874            let is_support_flag =
875                capability.starts_with("supports") || capability == "supportTerminateDebuggee";
876            if !is_support_flag || !raw_value.as_bool().unwrap_or(false) {
877                continue;
878            }
879
880            let command = capability_to_command
881                .iter()
882                .find_map(|(supported, command)| (*supported == capability).then_some(*command))
883                .ok_or_else(|| {
884                    format!(
885                        "Capability `{capability}` is true but has no handler mapping in this invariant test"
886                    )
887                })?;
888
889            let _ = mapped_commands.insert(command);
890        }
891
892        let mut request_seq = 2;
893        for command in mapped_commands {
894            let arguments = match command {
895                "configurationDone" => Some(json!({})),
896                "setFunctionBreakpoints" => {
897                    Some(json!({"breakpoints": [{ "name": "main::noop" }]}))
898                }
899                "setBreakpoints" => Some(json!({
900                    "source": { "path": "/tmp/capability_honesty.pl" },
901                    "breakpoints": [{ "line": 1, "hitCondition": ">= 1", "logMessage": "breakpoint hit" }]
902                })),
903                "setExceptionBreakpoints" => Some(json!({"filters": ["die"]})),
904                "evaluate" => Some(json!({"expression": "$x", "allowSideEffects": true})),
905                "setVariable" => {
906                    Some(json!({"variablesReference": 11, "name": "$x", "value": "1"}))
907                }
908                "variables" => Some(json!({"variablesReference": 11})),
909                "inlineValues" => Some(json!({
910                    "source": { "path": "/tmp/capability_honesty.pl" },
911                    "startLine": 1,
912                    "endLine": 1
913                })),
914                "terminate" => Some(json!({"restart": false})),
915                "completions" => Some(json!({"text": "pr", "column": 2})),
916                "modules" => Some(json!({})),
917                "restart" => Some(json!({})),
918                "exceptionInfo" => Some(json!({"threadId": 1})),
919                "breakpointLocations" => Some(json!({
920                    "source": { "path": "/tmp/capability_honesty.pl" },
921                    "line": 1
922                })),
923                "setExpression" => Some(json!({"expression": "$x", "value": "1"})),
924                "setDataBreakpoints" => Some(json!({"breakpoints": []})),
925                "loadedSources" => Some(json!({})),
926                "cancel" => Some(json!({})),
927                "stepInTargets" => Some(json!({"frameId": 1})),
928                "gotoTargets" => Some(json!({
929                    "source": { "path": "/tmp/capability_honesty.pl" },
930                    "line": 1
931                })),
932                "terminateThreads" => Some(json!({})),
933                _ => None,
934            };
935
936            let response = adapter.handle_request(request_seq, command, arguments);
937            request_seq += 1;
938
939            match response {
940                DapMessage::Response { command: actual, message, .. } => {
941                    assert_eq!(
942                        actual, command,
943                        "Capability-mapped command `{command}` must route to its handler"
944                    );
945                    let message_text = message.unwrap_or_default();
946                    assert!(
947                        !message_text.contains("Unknown command"),
948                        "Capability-mapped command `{command}` must not hit unknown-command path"
949                    );
950                }
951                _ => return Err(format!("Expected response for `{command}`").into()),
952            }
953        }
954
955        // supportsTerminateThreadsRequest matches feature advertising (now enabled)
956        let terminate_threads_expected =
957            crate::feature_catalog::has_feature("dap.terminate_threads");
958        assert_eq!(
959            capability_map.get("supportsTerminateThreadsRequest").and_then(|v| v.as_bool()),
960            Some(terminate_threads_expected),
961            "supportsTerminateThreadsRequest must match dap.terminate_threads feature setting"
962        );
963
964        Ok(())
965    }
966
967    #[test]
968    fn test_set_exception_breakpoints_toggles_die_filter() -> Result<(), Box<dyn std::error::Error>>
969    {
970        let mut adapter = DebugAdapter::new();
971
972        assert!(
973            !*lock_or_recover(
974                &adapter.exception_break_on_die,
975                "test_set_exception_breakpoints.initial"
976            ),
977            "die filter should default to disabled"
978        );
979
980        let response = adapter.handle_request(
981            1,
982            "setExceptionBreakpoints",
983            Some(json!({
984                "filters": ["die"]
985            })),
986        );
987        match response {
988            DapMessage::Response { success: true, command, .. } => {
989                assert_eq!(command, "setExceptionBreakpoints");
990            }
991            _ => return Err("Expected successful setExceptionBreakpoints response".into()),
992        }
993
994        assert!(
995            *lock_or_recover(
996                &adapter.exception_break_on_die,
997                "test_set_exception_breakpoints.enabled"
998            ),
999            "die filter should be enabled after request"
1000        );
1001
1002        let disable = adapter.handle_request(
1003            2,
1004            "setExceptionBreakpoints",
1005            Some(json!({
1006                "filters": []
1007            })),
1008        );
1009        match disable {
1010            DapMessage::Response { success: true, command, .. } => {
1011                assert_eq!(command, "setExceptionBreakpoints");
1012            }
1013            _ => return Err("Expected successful setExceptionBreakpoints response".into()),
1014        }
1015
1016        assert!(
1017            !*lock_or_recover(
1018                &adapter.exception_break_on_die,
1019                "test_set_exception_breakpoints.disabled"
1020            ),
1021            "die filter should be disabled when no matching filters are configured"
1022        );
1023
1024        Ok(())
1025    }
1026
1027    #[test]
1028    fn test_attach_missing_arguments() -> Result<(), Box<dyn std::error::Error>> {
1029        let mut adapter = DebugAdapter::new();
1030        let response = adapter.handle_request(1, "attach", None);
1031
1032        match response {
1033            DapMessage::Response { success, command, message, .. } => {
1034                assert!(!success);
1035                assert_eq!(command, "attach");
1036                assert!(message.is_some());
1037                let msg = message.ok_or("Expected message")?;
1038                assert!(msg.contains("Missing attach arguments"));
1039            }
1040            _ => return Err("Expected response".into()),
1041        }
1042        Ok(())
1043    }
1044
1045    #[test]
1046    fn test_attach_tcp_valid_arguments() -> Result<(), Box<dyn std::error::Error>> {
1047        let mut adapter = DebugAdapter::new();
1048        let args = json!({
1049            "host": "localhost",
1050            "port": 13603,
1051            "timeout": 5000
1052        });
1053        let response = adapter.handle_request(1, "attach", Some(args));
1054
1055        match response {
1056            DapMessage::Response { success, command, message, .. } => {
1057                assert!(!success); // Not yet implemented, but validates correctly
1058                assert_eq!(command, "attach");
1059                assert!(message.is_some());
1060                let msg = message.ok_or("Expected message")?;
1061                assert!(msg.contains("localhost:13603"));
1062                assert!(msg.contains("5000ms timeout"));
1063            }
1064            _ => return Err("Expected response".into()),
1065        }
1066        Ok(())
1067    }
1068
1069    #[test]
1070    fn test_attach_process_id_mode() -> Result<(), Box<dyn std::error::Error>> {
1071        let mut adapter = DebugAdapter::new();
1072        let args = json!({
1073            "processId": 12345
1074        });
1075        let response = adapter.handle_request(1, "attach", Some(args));
1076
1077        match response {
1078            DapMessage::Response { success, command, body, message, .. } => {
1079                assert!(success);
1080                assert_eq!(command, "attach");
1081                assert!(body.is_some());
1082                let body = body.ok_or("Expected body")?;
1083                assert_eq!(body.get("processId").and_then(|v| v.as_u64()), Some(12345));
1084                assert!(message.is_some());
1085                let msg = message.ok_or("Expected message")?;
1086                assert!(msg.contains("signal-control mode"));
1087            }
1088            _ => return Err("Expected response".into()),
1089        }
1090        Ok(())
1091    }
1092
1093    #[test]
1094    fn test_attach_empty_host() -> Result<(), Box<dyn std::error::Error>> {
1095        let mut adapter = DebugAdapter::new();
1096        let args = json!({
1097            "host": "",
1098            "port": 13603
1099        });
1100        let response = adapter.handle_request(1, "attach", Some(args));
1101
1102        match response {
1103            DapMessage::Response { success, command, message, .. } => {
1104                assert!(!success);
1105                assert_eq!(command, "attach");
1106                assert!(message.is_some());
1107                let msg = message.ok_or("Expected message")?;
1108                assert!(msg.contains("Host cannot be empty"));
1109            }
1110            _ => return Err("Expected response".into()),
1111        }
1112        Ok(())
1113    }
1114
1115    #[test]
1116    fn test_attach_whitespace_host() -> Result<(), Box<dyn std::error::Error>> {
1117        let mut adapter = DebugAdapter::new();
1118        let args = json!({
1119            "host": "   ",
1120            "port": 13603
1121        });
1122        let response = adapter.handle_request(1, "attach", Some(args));
1123
1124        match response {
1125            DapMessage::Response { success, command, message, .. } => {
1126                assert!(!success);
1127                assert_eq!(command, "attach");
1128                assert!(message.is_some());
1129                let msg = message.ok_or("Expected message")?;
1130                assert!(msg.contains("Host cannot be empty"));
1131            }
1132            _ => return Err("Expected response".into()),
1133        }
1134        Ok(())
1135    }
1136
1137    #[test]
1138    fn test_attach_zero_port() -> Result<(), Box<dyn std::error::Error>> {
1139        let mut adapter = DebugAdapter::new();
1140        let args = json!({
1141            "host": "localhost",
1142            "port": 0
1143        });
1144        let response = adapter.handle_request(1, "attach", Some(args));
1145
1146        match response {
1147            DapMessage::Response { success, command, message, .. } => {
1148                assert!(!success);
1149                assert_eq!(command, "attach");
1150                assert!(message.is_some());
1151                let msg = message.ok_or("Expected message")?;
1152                assert!(msg.contains("Port must be in range"));
1153            }
1154            _ => return Err("Expected response".into()),
1155        }
1156        Ok(())
1157    }
1158
1159    #[test]
1160    fn test_attach_zero_timeout() -> Result<(), Box<dyn std::error::Error>> {
1161        let mut adapter = DebugAdapter::new();
1162        let args = json!({
1163            "host": "localhost",
1164            "port": 13603,
1165            "timeout": 0
1166        });
1167        let response = adapter.handle_request(1, "attach", Some(args));
1168
1169        match response {
1170            DapMessage::Response { success, command, message, .. } => {
1171                assert!(!success);
1172                assert_eq!(command, "attach");
1173                assert!(message.is_some());
1174                let msg = message.ok_or("Expected message")?;
1175                assert!(msg.contains("Timeout must be greater than 0"));
1176            }
1177            _ => return Err("Expected response".into()),
1178        }
1179        Ok(())
1180    }
1181
1182    #[test]
1183    fn test_attach_excessive_timeout() -> Result<(), Box<dyn std::error::Error>> {
1184        let mut adapter = DebugAdapter::new();
1185        let args = json!({
1186            "host": "localhost",
1187            "port": 13603,
1188            "timeout": 400000
1189        });
1190        let response = adapter.handle_request(1, "attach", Some(args));
1191
1192        match response {
1193            DapMessage::Response { success, command, message, .. } => {
1194                assert!(!success);
1195                assert_eq!(command, "attach");
1196                assert!(message.is_some());
1197                let msg = message.ok_or("Expected message")?;
1198                assert!(msg.contains("Timeout cannot exceed"));
1199            }
1200            _ => return Err("Expected response".into()),
1201        }
1202        Ok(())
1203    }
1204
1205    #[test]
1206    fn test_attach_default_values() -> Result<(), Box<dyn std::error::Error>> {
1207        let mut adapter = DebugAdapter::new();
1208        // Empty args should use defaults and fail with missing arguments message
1209        let args = json!({});
1210        let response = adapter.handle_request(1, "attach", Some(args));
1211
1212        match response {
1213            DapMessage::Response { success, command, message, .. } => {
1214                assert!(!success);
1215                assert_eq!(command, "attach");
1216                assert!(message.is_some());
1217                // Should use default host/port but still not be implemented
1218                let msg = message.ok_or("Expected message")?;
1219                assert!(msg.contains("localhost:13603"));
1220            }
1221            _ => return Err("Expected response".into()),
1222        }
1223        Ok(())
1224    }
1225
1226    #[test]
1227    fn test_attach_custom_port() -> Result<(), Box<dyn std::error::Error>> {
1228        let mut adapter = DebugAdapter::new();
1229        let args = json!({
1230            "host": "192.168.1.100",
1231            "port": 9000
1232        });
1233        let response = adapter.handle_request(1, "attach", Some(args));
1234
1235        match response {
1236            DapMessage::Response { success, command, message, .. } => {
1237                assert!(!success); // Not yet implemented
1238                assert_eq!(command, "attach");
1239                assert!(message.is_some());
1240                let msg = message.ok_or("Expected message")?;
1241                assert!(msg.contains("192.168.1.100:9000"));
1242            }
1243            _ => return Err("Expected response".into()),
1244        }
1245        Ok(())
1246    }
1247
1248    #[test]
1249    fn test_attach_trims_host_for_tcp_target() -> Result<(), Box<dyn std::error::Error>> {
1250        let mut adapter = DebugAdapter::new();
1251        let args = json!({
1252            "host": " 192.168.1.100 ",
1253            "port": 9000
1254        });
1255        let response = adapter.handle_request(1, "attach", Some(args));
1256
1257        match response {
1258            DapMessage::Response { success, command, message, .. } => {
1259                assert!(!success);
1260                assert_eq!(command, "attach");
1261                assert!(message.is_some());
1262                let msg = message.ok_or("Expected message")?;
1263                assert!(msg.contains("192.168.1.100:9000"));
1264            }
1265            _ => return Err("Expected response".into()),
1266        }
1267        Ok(())
1268    }
1269
1270    #[test]
1271    fn test_attach_accepts_timeout_ms_alias() -> Result<(), Box<dyn std::error::Error>> {
1272        let mut adapter = DebugAdapter::new();
1273        let args = json!({
1274            "host": "localhost",
1275            "port": 13603,
1276            "timeoutMs": 0
1277        });
1278        let response = adapter.handle_request(1, "attach", Some(args));
1279
1280        match response {
1281            DapMessage::Response { success, command, message, .. } => {
1282                assert!(!success);
1283                assert_eq!(command, "attach");
1284                assert!(message.is_some());
1285                let msg = message.ok_or("Expected message")?;
1286                assert!(msg.contains("Timeout must be greater than 0"));
1287            }
1288            _ => return Err("Expected response".into()),
1289        }
1290        Ok(())
1291    }
1292
1293    #[test]
1294    fn test_tcp_session_threads_non_empty() -> Result<(), Box<dyn std::error::Error>> {
1295        let adapter = DebugAdapter::new();
1296        // Inject a TcpAttachSession so handle_threads sees it
1297        {
1298            let mut guard = lock_or_recover(&adapter.tcp_session, "test.tcp_session");
1299            *guard = Some(TcpAttachSession::new());
1300        }
1301        let response = adapter.handle_threads(1, 1);
1302        match response {
1303            DapMessage::Response { success, body: Some(body), .. } => {
1304                assert!(success);
1305                let threads = body["threads"].as_array().ok_or("threads must be array")?;
1306                assert!(!threads.is_empty(), "TCP attach should return non-empty threads");
1307                assert_eq!(threads[0]["id"], 1);
1308                assert_eq!(threads[0]["name"], "TCP Attached Thread");
1309            }
1310            _ => return Err("Expected successful response with body".into()),
1311        }
1312        Ok(())
1313    }
1314
1315    #[test]
1316    fn test_attach_port_out_of_range() -> Result<(), Box<dyn std::error::Error>> {
1317        let mut adapter = DebugAdapter::new();
1318        // Initialize first so attach is allowed
1319        let _ = adapter.handle_request(1, "initialize", None);
1320
1321        for port in [65536_u64, 70000, u64::MAX] {
1322            let args = json!({ "port": port });
1323            let response = adapter.handle_request(2, "attach", Some(args));
1324            match response {
1325                DapMessage::Response { success, message, .. } => {
1326                    assert!(!success, "port {port} should be rejected");
1327                    assert!(
1328                        message.as_ref().is_some_and(|m| m.contains("out of range")),
1329                        "expected 'out of range' error for port {port}, got: {message:?}"
1330                    );
1331                }
1332                _ => return Err(format!("Expected error response for port {port}").into()),
1333            }
1334        }
1335        Ok(())
1336    }
1337
1338    #[test]
1339    fn test_attach_port_valid_boundary() {
1340        let mut adapter = DebugAdapter::new();
1341        let _ = adapter.handle_request(1, "initialize", None);
1342
1343        // Port 1 and 65535 should pass port validation (may fail later at TCP connect)
1344        for port in [1_u64, 65535] {
1345            let args = json!({ "port": port });
1346            let response = adapter.handle_request(2, "attach", Some(args));
1347            if let DapMessage::Response { message, .. } = response {
1348                // Should NOT contain "out of range" — it passed validation
1349                assert!(
1350                    !message.as_ref().is_some_and(|m| m.contains("out of range")),
1351                    "port {port} should pass range validation, got: {message:?}"
1352                );
1353            }
1354        }
1355    }
1356
1357    #[test]
1358    fn test_goto_missing_arguments() -> Result<(), Box<dyn std::error::Error>> {
1359        let mut adapter = DebugAdapter::new();
1360        let response = adapter.handle_request(1, "goto", None);
1361        match response {
1362            DapMessage::Response { success, command, message, .. } => {
1363                assert!(!success);
1364                assert_eq!(command, "goto");
1365                assert_eq!(message.as_deref(), Some("Missing or invalid arguments"));
1366            }
1367            _ => return Err("Expected response".into()),
1368        }
1369        Ok(())
1370    }
1371
1372    #[test]
1373    fn test_goto_invalid_target() -> Result<(), Box<dyn std::error::Error>> {
1374        let mut adapter = DebugAdapter::new();
1375        let response =
1376            adapter.handle_request(1, "goto", Some(json!({"threadId": 1, "targetId": -1})));
1377        match response {
1378            DapMessage::Response { success, command, message, .. } => {
1379                assert!(!success);
1380                assert_eq!(command, "goto");
1381                // With target mapping, unknown IDs produce "Unknown goto target id"
1382                let msg = message.as_deref().unwrap_or("");
1383                assert!(
1384                    msg.contains("Unknown goto target"),
1385                    "expected unknown target message, got: {msg}"
1386                );
1387            }
1388            _ => return Err("Expected response".into()),
1389        }
1390        Ok(())
1391    }
1392
1393    #[test]
1394    fn test_goto_no_session() -> Result<(), Box<dyn std::error::Error>> {
1395        let mut adapter = DebugAdapter::new();
1396        // First store a mapping so goto gets past the lookup
1397        {
1398            let mut goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1399            goto_map.insert(10, ("/test/file.pl".to_string(), 10));
1400        }
1401        let response =
1402            adapter.handle_request(1, "goto", Some(json!({"threadId": 1, "targetId": 10})));
1403        match response {
1404            DapMessage::Response { success, command, message, .. } => {
1405                assert!(!success);
1406                assert_eq!(command, "goto");
1407                assert_eq!(message.as_deref(), Some("No active debug session"));
1408            }
1409            _ => return Err("Expected response".into()),
1410        }
1411        Ok(())
1412    }
1413
1414    #[test]
1415    fn test_terminate_threads_capability_is_advertised_when_feature_enabled()
1416    -> Result<(), Box<dyn std::error::Error>> {
1417        let mut adapter = DebugAdapter::new();
1418        let init = adapter.handle_request(1, "initialize", None);
1419        let capabilities = match init {
1420            DapMessage::Response { success: true, body: Some(body), .. } => body,
1421            _ => return Err("Expected successful initialize response".into()),
1422        };
1423        let cap_map = capabilities.as_object().ok_or("body must be object")?;
1424        let expected = crate::feature_catalog::has_feature("dap.terminate_threads");
1425        assert_eq!(
1426            cap_map.get("supportsTerminateThreadsRequest").and_then(|v| v.as_bool()),
1427            Some(expected),
1428            "supportsTerminateThreadsRequest must match dap.terminate_threads feature setting"
1429        );
1430        Ok(())
1431    }
1432
1433    #[test]
1434    fn test_goto_targets_then_goto_flow() -> Result<(), Box<dyn std::error::Error>> {
1435        let mut adapter = DebugAdapter::new();
1436
1437        // gotoTargets should succeed (even with no file — returns empty targets)
1438        let gt_response = adapter.handle_request(
1439            1,
1440            "gotoTargets",
1441            Some(json!({"source": {"path": "/tmp/nonexistent.pl"}, "line": 1})),
1442        );
1443        match gt_response {
1444            DapMessage::Response { success, command, message, .. } => {
1445                assert!(success, "gotoTargets should succeed");
1446                assert_eq!(command, "gotoTargets");
1447                // Must NOT say "does not support"
1448                assert!(
1449                    !message.as_deref().unwrap_or("").contains("does not support"),
1450                    "gotoTargets must not claim lack of support"
1451                );
1452            }
1453            _ => return Err("Expected response".into()),
1454        }
1455
1456        // goto should fail gracefully with unknown target (no stored mapping)
1457        let goto_response =
1458            adapter.handle_request(2, "goto", Some(json!({"threadId": 1, "targetId": 999})));
1459        match goto_response {
1460            DapMessage::Response { success, command, message, .. } => {
1461                assert!(!success, "goto with unknown target should fail");
1462                assert_eq!(command, "goto");
1463                let msg = message.as_deref().unwrap_or("");
1464                assert!(
1465                    msg.contains("Unknown goto target"),
1466                    "goto must report unknown target, got: {msg}"
1467                );
1468            }
1469            _ => return Err("Expected response".into()),
1470        }
1471        Ok(())
1472    }
1473
1474    #[test]
1475    fn test_goto_targets_stores_mapping() -> Result<(), Box<dyn std::error::Error>> {
1476        use std::io::Write;
1477
1478        let mut adapter = DebugAdapter::new();
1479        adapter.handle_request(1, "initialize", None);
1480
1481        // Create a temp file with executable content
1482        let dir = tempfile::tempdir()?;
1483        let file_path = dir.path().join("test_goto.pl");
1484        {
1485            let mut f = std::fs::File::create(&file_path)?;
1486            writeln!(f, "my $x = 1;")?;
1487            writeln!(f, "my $y = 2;")?;
1488            writeln!(f, "print $x + $y;")?;
1489        }
1490
1491        let path_str = file_path.to_string_lossy().to_string();
1492        let response = adapter.handle_request(
1493            2,
1494            "gotoTargets",
1495            Some(json!({
1496                "source": {"path": path_str},
1497                "line": 2
1498            })),
1499        );
1500
1501        // Verify the response contains targets with monotonic IDs (not line numbers)
1502        match response {
1503            DapMessage::Response { success, body: Some(body), .. } => {
1504                assert!(success, "gotoTargets should succeed");
1505                let targets = body
1506                    .get("targets")
1507                    .and_then(|t| t.as_array())
1508                    .ok_or("should have targets array")?;
1509                assert!(!targets.is_empty(), "should find executable lines");
1510
1511                // Verify IDs are monotonic starting from 1, NOT equal to line numbers
1512                let first_id = targets[0].get("id").and_then(|v| v.as_i64()).unwrap_or(0);
1513                assert!(first_id >= 1, "IDs should start at 1 or higher");
1514
1515                // Verify the mapping was stored internally
1516                let goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1517                assert!(!goto_map.is_empty(), "goto_targets map should be populated");
1518                // Each stored entry should reference our temp file
1519                for (_id, (stored_path, _line)) in goto_map.iter() {
1520                    assert_eq!(stored_path, &path_str, "stored path should match source");
1521                }
1522            }
1523            _ => return Err("Expected successful response".into()),
1524        }
1525
1526        let _ = std::fs::remove_file(&file_path);
1527        Ok(())
1528    }
1529
1530    #[test]
1531    fn test_goto_uses_stored_mapping() -> Result<(), Box<dyn std::error::Error>> {
1532        let mut adapter = DebugAdapter::new();
1533        adapter.handle_request(1, "initialize", None);
1534
1535        // Manually populate the goto_targets map to simulate handle_goto_targets
1536        {
1537            let mut goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1538            goto_map.insert(42, ("/some/file.pl".to_string(), 10));
1539        }
1540
1541        // Without a debug session, goto should fail with "No active debug session"
1542        // but only after successfully looking up the target
1543        let response =
1544            adapter.handle_request(2, "goto", Some(json!({"threadId": 1, "targetId": 42})));
1545        match response {
1546            DapMessage::Response { success, command, message, .. } => {
1547                assert!(!success, "goto without session should fail");
1548                assert_eq!(command, "goto");
1549                // It should NOT say "Unknown goto target" — the mapping was found
1550                let msg = message.as_deref().unwrap_or("");
1551                assert!(
1552                    msg.contains("No active debug session"),
1553                    "goto should report no session, got: {msg}"
1554                );
1555            }
1556            _ => return Err("Expected response".into()),
1557        }
1558
1559        // Verify the consumed entry was removed from the map
1560        let goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1561        assert!(!goto_map.contains_key(&42), "consumed goto target should be removed from map");
1562        Ok(())
1563    }
1564
1565    #[test]
1566    fn test_inline_values_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1567        let mut adapter = DebugAdapter::new();
1568        adapter.handle_request(1, "initialize", None);
1569
1570        let dir = tempfile::tempdir()?;
1571        *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1572            Some(dir.path().to_path_buf());
1573
1574        let response = adapter.handle_request(
1575            2,
1576            "inlineValues",
1577            Some(json!({
1578                "source": {"path": "../../../etc/passwd"},
1579                "startLine": 1,
1580                "endLine": 1
1581            })),
1582        );
1583        match response {
1584            DapMessage::Response { success, command, message, .. } => {
1585                assert!(!success, "inlineValues with traversal path should fail");
1586                assert_eq!(command, "inlineValues");
1587                let msg = message.as_deref().unwrap_or("");
1588                assert!(
1589                    msg.contains("Path validation failed"),
1590                    "should report path validation failure, got: {msg}"
1591                );
1592            }
1593            _ => return Err("Expected response".into()),
1594        }
1595        Ok(())
1596    }
1597
1598    #[test]
1599    fn test_source_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1600        let mut adapter = DebugAdapter::new();
1601        adapter.handle_request(1, "initialize", None);
1602
1603        // Set workspace root to a temp directory
1604        let dir = tempfile::tempdir()?;
1605        *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1606            Some(dir.path().to_path_buf());
1607
1608        let response = adapter.handle_request(
1609            2,
1610            "source",
1611            Some(json!({
1612                "source": {"path": "../../../etc/passwd"},
1613                "sourceReference": 0
1614            })),
1615        );
1616        match response {
1617            DapMessage::Response { success, command, message, .. } => {
1618                assert!(!success, "source with traversal path should fail");
1619                assert_eq!(command, "source");
1620                let msg = message.as_deref().unwrap_or("");
1621                assert!(
1622                    msg.contains("Path validation failed"),
1623                    "should report path validation failure, got: {msg}"
1624                );
1625            }
1626            _ => return Err("Expected response".into()),
1627        }
1628        Ok(())
1629    }
1630
1631    #[test]
1632    fn test_breakpoint_locations_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1633        let mut adapter = DebugAdapter::new();
1634        adapter.handle_request(1, "initialize", None);
1635
1636        let dir = tempfile::tempdir()?;
1637        *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1638            Some(dir.path().to_path_buf());
1639
1640        let response = adapter.handle_request(
1641            2,
1642            "breakpointLocations",
1643            Some(json!({
1644                "source": {"path": "../../../etc/passwd"},
1645                "line": 1
1646            })),
1647        );
1648        match response {
1649            DapMessage::Response { success, command, message, .. } => {
1650                assert!(!success, "breakpointLocations with traversal path should fail");
1651                assert_eq!(command, "breakpointLocations");
1652                let msg = message.as_deref().unwrap_or("");
1653                assert!(
1654                    msg.contains("Path validation failed"),
1655                    "should report path validation failure, got: {msg}"
1656                );
1657            }
1658            _ => return Err("Expected response".into()),
1659        }
1660        Ok(())
1661    }
1662
1663    #[test]
1664    fn test_goto_targets_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1665        let mut adapter = DebugAdapter::new();
1666        adapter.handle_request(1, "initialize", None);
1667
1668        let dir = tempfile::tempdir()?;
1669        *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1670            Some(dir.path().to_path_buf());
1671
1672        let response = adapter.handle_request(
1673            2,
1674            "gotoTargets",
1675            Some(json!({
1676                "source": {"path": "../../../etc/passwd"},
1677                "line": 1
1678            })),
1679        );
1680        match response {
1681            DapMessage::Response { success, command, message, .. } => {
1682                assert!(!success, "gotoTargets with traversal path should fail");
1683                assert_eq!(command, "gotoTargets");
1684                let msg = message.as_deref().unwrap_or("");
1685                assert!(
1686                    msg.contains("Path validation failed"),
1687                    "should report path validation failure, got: {msg}"
1688                );
1689            }
1690            _ => return Err("Expected response".into()),
1691        }
1692        Ok(())
1693    }
1694
1695    // --- Signal handling tests (#3028) ---
1696
1697    #[test]
1698    fn test_send_continue_signal_does_not_panic_on_pid_1() {
1699        // PID 1 on Unix is init (EPERM), on Windows GenerateConsoleCtrlEvent returns 0.
1700        // Must not panic on any platform.
1701        let adapter = DebugAdapter::new();
1702        let _ = adapter.send_continue_signal(1);
1703    }
1704
1705    #[test]
1706    fn test_send_interrupt_signal_does_not_panic_on_pid_1() {
1707        let adapter = DebugAdapter::new();
1708        let _ = adapter.send_interrupt_signal(1);
1709    }
1710
1711    #[test]
1712    fn test_send_continue_signal_pid_zero_returns_false() {
1713        let adapter = DebugAdapter::new();
1714        assert!(!adapter.send_continue_signal(0));
1715    }
1716
1717    #[test]
1718    fn test_send_interrupt_signal_pid_zero_returns_false() {
1719        let adapter = DebugAdapter::new();
1720        assert!(!adapter.send_interrupt_signal(0));
1721    }
1722
1723    #[test]
1724    #[cfg(unix)]
1725    fn test_send_continue_signal_nonexistent_pid_returns_false() {
1726        let adapter = DebugAdapter::new();
1727        assert!(!adapter.send_continue_signal(999_999));
1728    }
1729
1730    #[test]
1731    #[cfg(unix)]
1732    fn test_send_interrupt_signal_nonexistent_pid_returns_false() {
1733        let adapter = DebugAdapter::new();
1734        assert!(!adapter.send_interrupt_signal(999_999));
1735    }
1736
1737    // ── context_re unit tests ─────────────────────────────────────────────────
1738
1739    /// Helper: apply context_re to `line` and return (file, line_num) if matched.
1740    fn apply_context_re(line: &str) -> Option<(String, String)> {
1741        let re = context_re()?;
1742        let caps = re.captures(line)?;
1743        let file =
1744            caps.name("file").or_else(|| caps.name("file2")).map(|m| m.as_str().to_string())?;
1745        let line_num =
1746            caps.name("line").or_else(|| caps.name("line2")).map(|m| m.as_str().to_string())?;
1747        Some((file, line_num))
1748    }
1749
1750    #[test]
1751    fn test_context_re_unix_path() {
1752        let result = apply_context_re("main::(/path/to/file.pl:42):");
1753        assert_eq!(result, Some(("/path/to/file.pl".to_string(), "42".to_string())));
1754    }
1755
1756    #[test]
1757    fn test_context_re_windows_drive_letter_backslash() {
1758        // Windows drive-letter path: colon followed by backslash must be captured
1759        // as part of the file path, not treated as a line-number separator.
1760        let result = apply_context_re(r"main::(C:\Users\name\file.pl:42):");
1761        assert_eq!(result, Some((r"C:\Users\name\file.pl".to_string(), "42".to_string())));
1762    }
1763
1764    #[test]
1765    fn test_context_re_windows_drive_letter_forward_slash() {
1766        // Forward-slash Windows path from Git Bash / cross-platform tools.
1767        let result = apply_context_re("main::(C:/Users/file.pl:7):");
1768        assert_eq!(result, Some(("C:/Users/file.pl".to_string(), "7".to_string())));
1769    }
1770
1771    #[test]
1772    fn test_context_re_unc_path() {
1773        // UNC path (Windows network share).
1774        let result = apply_context_re(r"main::(\\server\share\file.pl:5):");
1775        assert_eq!(result, Some((r"\\server\share\file.pl".to_string(), "5".to_string())));
1776    }
1777
1778    #[test]
1779    fn test_context_re_named_function() {
1780        // Func::Name context line.
1781        let result = apply_context_re("Foo::Bar::(/path/script.pl:10):");
1782        assert_eq!(result, Some(("/path/script.pl".to_string(), "10".to_string())));
1783    }
1784
1785    #[test]
1786    fn test_context_re_windows_path_named_function() {
1787        // Func::Name context line with Windows path.
1788        let result = apply_context_re(r"Foo::Bar::(C:\path\script.pl:10):");
1789        assert_eq!(result, Some((r"C:\path\script.pl".to_string(), "10".to_string())));
1790    }
1791
1792    #[test]
1793    fn test_context_re_no_match_path_with_spaces() {
1794        // Paths with spaces do not match — the character class excludes \s.
1795        let result = apply_context_re("main::(/path with spaces/file.pl:5):");
1796        assert!(result.is_none(), "paths with spaces should not match");
1797    }
1798
1799    #[test]
1800    fn test_context_re_colon_digit_is_line_separator() {
1801        // Colon followed by digit is the line-number separator, not a path component.
1802        // "/path/file.pl:42" should yield file="/path/file.pl", line="42".
1803        let result = apply_context_re("main::(/path/file.pl:42):");
1804        assert_eq!(result, Some(("/path/file.pl".to_string(), "42".to_string())));
1805    }
1806
1807    // Helper to create a test stack frame for #964 and #933 tests
1808    fn make_test_frame(line_num: i32) -> StackFrame {
1809        StackFrame::new(
1810            line_num,
1811            format!("test_func::{}", line_num),
1812            Source::new(format!("/test/file{}.pl", line_num)),
1813            line_num,
1814        )
1815    }
1816
1817    #[test]
1818    fn test_handle_continue_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1819        let adapter = DebugAdapter::new();
1820        adapter.seed_session_for_test();
1821        adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1822
1823        // Precondition: frames are present
1824        assert_eq!(
1825            adapter.stack_frames_snapshot_for_test().len(),
1826            2,
1827            "precondition: should have 2 frames before continue"
1828        );
1829
1830        // Call the handler (this should clear stack_frames)
1831        let _response = adapter.handle_continue(1, 1, None);
1832
1833        // Assert: frames are now cleared (FAILS if fix not implemented)
1834        assert_eq!(
1835            adapter.stack_frames_snapshot_for_test().len(),
1836            0,
1837            "handle_continue must clear stack_frames after resume"
1838        );
1839        Ok(())
1840    }
1841
1842    #[test]
1843    fn test_handle_next_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1844        let adapter = DebugAdapter::new();
1845        adapter.seed_session_for_test();
1846        adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1847
1848        assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1849        let _response = adapter.handle_next(1, 1, None);
1850        assert_eq!(
1851            adapter.stack_frames_snapshot_for_test().len(),
1852            0,
1853            "handle_next must clear stack_frames after resume"
1854        );
1855        Ok(())
1856    }
1857
1858    #[test]
1859    fn test_handle_step_in_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1860        let adapter = DebugAdapter::new();
1861        adapter.seed_session_for_test();
1862        adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1863
1864        assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1865        let _response = adapter.handle_step_in(1, 1, None);
1866        assert_eq!(
1867            adapter.stack_frames_snapshot_for_test().len(),
1868            0,
1869            "handle_step_in must clear stack_frames after resume"
1870        );
1871        Ok(())
1872    }
1873
1874    #[test]
1875    fn test_handle_step_out_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1876        let adapter = DebugAdapter::new();
1877        adapter.seed_session_for_test();
1878        adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1879
1880        assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1881        let _response = adapter.handle_step_out(1, 1, None);
1882        assert_eq!(
1883            adapter.stack_frames_snapshot_for_test().len(),
1884            0,
1885            "handle_step_out must clear stack_frames after resume"
1886        );
1887        Ok(())
1888    }
1889
1890    #[test]
1891    fn test_handle_pause_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1892        let adapter = DebugAdapter::new();
1893        adapter.seed_session_for_test();
1894        adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1895
1896        assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1897        let _response = adapter.handle_pause(1, 1, None);
1898        assert_eq!(
1899            adapter.stack_frames_snapshot_for_test().len(),
1900            0,
1901            "handle_pause must clear stack_frames after pause"
1902        );
1903        Ok(())
1904    }
1905
1906    #[test]
1907    fn test_handle_goto_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1908        // #964: handle_goto is the 6th resume handler that must clear stack_frames.
1909        // It clears inside the `if let Some(session) && stdin` arm, so a seeded session
1910        // and a resolvable goto target are both required to exercise the clear path.
1911        let adapter = DebugAdapter::new();
1912        adapter.seed_session_for_test();
1913        adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1914
1915        // Precondition: stale frames are present
1916        assert_eq!(
1917            adapter.stack_frames_snapshot_for_test().len(),
1918            2,
1919            "precondition: should have 2 frames before goto"
1920        );
1921
1922        // Seed a goto target so handle_goto resolves it (otherwise returns early with
1923        // "Unknown goto target" before reaching the clear).
1924        {
1925            let mut goto_map =
1926                lock_or_recover(&adapter.goto_targets, "test.handle_goto_clears_stack_frames");
1927            goto_map.insert(1, ("/tmp/test_goto.pl".to_string(), 5));
1928        }
1929
1930        // Call handle_goto -- writes commands to the noop child's stdin (bytes are
1931        // discarded by the no-op process); stack_frames.clear() must still fire.
1932        let _response = adapter.handle_goto(1, 1, Some(json!({"threadId": 1, "targetId": 1})));
1933
1934        // Assert: frames cleared (FAILS if handle_goto does not call stack_frames.clear())
1935        assert_eq!(
1936            adapter.stack_frames_snapshot_for_test().len(),
1937            0,
1938            "handle_goto must clear stack_frames after resume"
1939        );
1940        Ok(())
1941    }
1942
1943    #[test]
1944    fn test_configuration_done_without_launch_should_fail() -> Result<(), Box<dyn std::error::Error>>
1945    {
1946        let mut adapter = DebugAdapter::new();
1947
1948        // Call initialize first (correct)
1949        let init_response = adapter.handle_request(1, "initialize", None);
1950        match init_response {
1951            DapMessage::Response { success: true, command, .. } => {
1952                assert_eq!(command, "initialize");
1953            }
1954            _ => return Err("Initialize should succeed".into()),
1955        }
1956
1957        // Call configurationDone WITHOUT calling launch first (incorrect sequence)
1958        let config_response = adapter.handle_request(2, "configurationDone", None);
1959
1960        // This should FAIL because no session exists (launch was never called)
1961        match config_response {
1962            DapMessage::Response { success: false, command, message, .. } => {
1963                assert_eq!(command, "configurationDone");
1964                assert!(message.is_some(), "should provide error message");
1965                let msg = message.ok_or("Expected error message")?;
1966                assert!(
1967                    msg.contains("No active debug session")
1968                        || msg.contains("launch")
1969                        || msg.contains("session"),
1970                    "Error message should explain that launch was not called, got: {msg}"
1971                );
1972                Ok(())
1973            }
1974            DapMessage::Response { success: true, .. } => {
1975                Err("configurationDone should FAIL when no launch has been called".into())
1976            }
1977            _ => Err("Expected response".into()),
1978        }
1979    }
1980
1981    #[test]
1982    fn test_launch_before_initialize_should_fail() -> Result<(), Box<dyn std::error::Error>> {
1983        let mut adapter = DebugAdapter::new();
1984
1985        // Try to call launch WITHOUT calling initialize first (incorrect sequence)
1986        let launch_response = adapter.handle_request(
1987            1,
1988            "launch",
1989            Some(json!({
1990                "program": "/tmp/test.pl"
1991            })),
1992        );
1993
1994        // This should FAIL because initialize was never called
1995        match launch_response {
1996            DapMessage::Response { success: false, command, message, .. } => {
1997                assert_eq!(command, "launch");
1998                assert!(message.is_some(), "should provide error message");
1999                let msg = message.ok_or("Expected error message")?;
2000                assert!(
2001                    msg.contains("initialize")
2002                        || msg.contains("Initialize")
2003                        || msg.contains("session"),
2004                    "Error message should explain that initialize is required, got: {msg}"
2005                );
2006                Ok(())
2007            }
2008            DapMessage::Response { success: true, .. } => {
2009                Err("launch should FAIL when initialize has not been called".into())
2010            }
2011            _ => Err("Expected response".into()),
2012        }
2013    }
2014}