Skip to main content

perl_dap/debug_adapter/
variables.rs

1//! Variable inspection: variable display, scope variables, set variable.
2
3use super::*;
4
5impl DebugAdapter {
6    /// Handle variables request
7    pub fn handle_variables(
8        &self,
9        seq: i64,
10        request_seq: i64,
11        arguments: Option<Value>,
12    ) -> DapMessage {
13        let args: VariablesArguments = match arguments.and_then(|v| serde_json::from_value(v).ok())
14        {
15            Some(a) => a,
16            None => {
17                return DapMessage::Response {
18                    seq,
19                    request_seq,
20                    success: false,
21                    command: "variables".to_string(),
22                    body: None,
23                    message: Some("Missing arguments".to_string()),
24                };
25            }
26        };
27
28        if args.start.is_some_and(|start| start < 0) {
29            return DapMessage::Response {
30                seq,
31                request_seq,
32                success: false,
33                command: "variables".to_string(),
34                body: None,
35                message: Some("Invalid start: must be >= 0".to_string()),
36            };
37        }
38
39        if args.count.is_some_and(|count| count < 0) {
40            return DapMessage::Response {
41                seq,
42                request_seq,
43                success: false,
44                command: "variables".to_string(),
45                body: None,
46                message: Some("Invalid count: must be >= 0".to_string()),
47            };
48        }
49
50        // Clamp i64 → i32 safely: values outside [1, i32::MAX] cannot encode a valid scope ref
51        // (scope encoding is frame_id * 10 + {1,2,3}, all positive). Negative, zero, or
52        // out-of-i32-range refs return protocol-safe empty per DAP spec — success=true, variables=[].
53        // We check the raw i64 first to catch huge positive overflow before saturation would
54        // hide it (i64::MAX saturates to i32::MAX, which is a non-zero i32 and would pass
55        // a simple `== 0` check — wrong). Refs in (0, i32::MAX] are passed to i64_to_i32_saturating.
56        let variables_ref_raw = args.variables_reference;
57        if variables_ref_raw <= 0 || variables_ref_raw > i32::MAX as i64 {
58            // Out-of-range: return protocol-safe empty response immediately.
59            // totalVariables is omitted (not 0) — DAP spec says omit optional fields
60            // when the value is not meaningful (invalid ref has no defined total).
61            return DapMessage::Response {
62                seq,
63                request_seq,
64                success: true,
65                command: "variables".to_string(),
66                body: Some(json!({ "variables": [] })),
67                message: None,
68            };
69        }
70        let variables_ref = Self::i64_to_i32_saturating(variables_ref_raw);
71
72        let start = args.start.unwrap_or(0) as usize;
73        let count = args.count.map(|v| v as usize).unwrap_or(256).clamp(1, 1024);
74
75        // Stale-ref guard: if a session exists but the debugger is not stopped, the cache
76        // has been cleared (variable_cache.clear() is called on every continue/step). Any
77        // variablesReference the client holds from the previous stop is stale. Querying
78        // the debugger while it is running would hang or produce garbage. Return the
79        // protocol-safe honest empty immediately.
80        {
81            let session_guard = lock_or_recover(&self.session, "debug_adapter.session");
82            if let Some(ref session) = *session_guard {
83                if session.state != DebugState::Stopped {
84                    // Not stopped: variable refs are stale. Omit totalVariables —
85                    // we have no meaningful count when not paused.
86                    return DapMessage::Response {
87                        seq,
88                        request_seq,
89                        success: true,
90                        command: "variables".to_string(),
91                        body: Some(json!({ "variables": [] })),
92                        message: None,
93                    };
94                }
95            }
96        }
97
98        // AC8.4: Render scalars/arrays/hashes with lazy child expansion.
99        let parsed_from_output;
100        let mut parsed_child_cache = HashMap::new();
101        let mut parsed_full_roots = Vec::new();
102        let mut used_session_cache = false;
103        // Total count from cache (populated on cache-hit path when full count is known).
104        let mut cached_total: Option<usize> = None;
105
106        if let Some(ref mut session) = *lock_or_recover(&self.session, "debug_adapter.session") {
107            // Serve requested pages from cache for stable references and cheap repeated expansion.
108            if let Some(vars) = session.variable_cache.get_page(variables_ref, start, count) {
109                used_session_cache = true;
110                // Capture the full count from the cache entry so totalVariables is correct
111                // even on subsequent (paged) requests where parsed_full_roots is not repopulated.
112                cached_total = session.variable_cache.root_count(variables_ref);
113                parsed_from_output = vars;
114            } else {
115                let mut framed_scope_lines = None;
116
117                // Request fresh scope output from Perl debugger for scope roots only.
118                //
119                // Decode the variablesReference using the VariableReference codec.
120                // Scope variants map to their kind (Locals/Package/Globals) and frame_id.
121                // Non-Scope variants and invalid refs skip the framed output fetch;
122                // EvalResult cache hits were already served above.
123                use crate::debug_adapter::var_ref::{ScopeKind, VariableReference};
124
125                // Short-circuit: stale EvalResult ref (cache miss after resume).
126                //
127                // On resume (continue/next/step), variable_cache.clear() runs, making
128                // any eval_ref the client holds from the previous stop stale. A stale
129                // eval_ref is in the EvalResult band ([1_000_000, 1_999_999_999]) but is
130                // absent from the cache. Querying the debugger for a bogus scope or waiting
131                // for output that will never arrive is wasteful and semantically wrong.
132                //
133                // Protocol contract: return honest empty (success=true, variables=[])
134                // immediately. This matches the DAP spec — a ref that is no longer valid
135                // after a resume simply has no children.
136                if matches!(
137                    VariableReference::decode(variables_ref),
138                    Some(VariableReference::EvalResult { .. })
139                ) {
140                    // Stale eval ref after resume — omit totalVariables; no meaningful count.
141                    return DapMessage::Response {
142                        seq,
143                        request_seq,
144                        success: true,
145                        command: "variables".to_string(),
146                        body: Some(json!({ "variables": [] })),
147                        message: None,
148                    };
149                }
150
151                let (scope_frame_id, scope_kind) = match VariableReference::decode(variables_ref) {
152                    Some(VariableReference::Scope { frame_id, kind }) => (frame_id, Some(kind)),
153                    _ => (0, None),
154                };
155                match scope_kind {
156                    Some(ScopeKind::Locals) => {
157                        // Locals scope: enumerate lexical `my` variables in the current
158                        // executing frame's pad using the B introspection module.
159                        //
160                        // Why not `V <frame_id> .`?  The `V` command takes a PACKAGE NAME,
161                        // not a frame number.  Passing a numeric frame_id (e.g. `V 1 .`)
162                        // looks up a package named "1" (which does not exist) and returns
163                        // no output.  The subsequent fallback to `fallback_scope_variables`
164                        // then returns fake DB-internal placeholders (`$self`, `@_`).
165                        //
166                        // The B-module eval approach:
167                        //   1. Gets the current frame's CV via `$DB::sub` (set by perl5db.pl
168                        //      to the sub name when stopped inside a subroutine, undef at
169                        //      file scope) or `B::main_cv()` for the file-scope frame.
170                        //   2. Walks the pad name list and value list in parallel,
171                        //      using `$va[-1]` (the last/innermost pad) so recursive
172                        //      calls show the current-innermost frame, not the outermost.
173                        //   3. Emits one `$name = value` line per lexical variable,
174                        //      which is the same format the `V` command would produce for
175                        //      package variables — fully compatible with `parse_scope_variables_from_lines`.
176                        //
177                        // The outer `eval {}` absorbs any errors (e.g. B not loadable) and
178                        // returns an empty string, so the framed output will be empty and
179                        // the adapter falls through to `parse_scope_variables_from_output`.
180                        if let Some(stdin) = session.process.stdin.as_mut() {
181                            let cmd = concat!(
182                                "p eval { require B; ",
183                                "my $cv=$DB::sub?B::svref_2object(\\&{$DB::sub}):B::main_cv(); ",
184                                "my $pl=$cv->PADLIST; ",
185                                "my @nm=$pl->NAMES->ARRAY; ",
186                                "my @va=$pl->ARRAY; ",
187                                "my @pds=(@va>1)?$va[-1]->ARRAY:(); ",
188                                "my $o=''; ",
189                                "for my $i (0..$#nm) { ",
190                                "  my $n=$nm[$i]; ",
191                                "  next if ref($n) eq 'B::SPECIAL'; ",
192                                "  my $pv=eval{$n->PVX}//''; ",
193                                "  next unless $pv=~/^[\\$\\@%]/; ",
194                                "  my $s=$i<@pds?$pds[$i]:undef; ",
195                                "  next unless defined $s; ",
196                                "  my $v=eval{$s->SV->PV}//eval{$s->SV->IV}//eval{$s->IV}//eval{$s->PV}//'undef'; ",
197                                "  $o.=\"$pv = $v\\n\" ",
198                                "} $o }",
199                            );
200                            let commands = vec![cmd.to_string()];
201                            match self.send_framed_debugger_commands(stdin, &commands) {
202                                Ok((begin, end)) => {
203                                    framed_scope_lines = self.capture_framed_debugger_output(
204                                        &begin,
205                                        &end,
206                                        DEBUGGER_QUERY_WAIT_MS * 8,
207                                    );
208                                }
209                                Err(error) => {
210                                    tracing::warn!(%error, "Failed to send framed locals command, falling back");
211                                }
212                            }
213                        }
214                    }
215                    Some(ScopeKind::Package) => {
216                        if let Some(stdin) = session.process.stdin.as_mut() {
217                            let commands = vec![format!("V {} ::", scope_frame_id)];
218                            match self.send_framed_debugger_commands(stdin, &commands) {
219                                Ok((begin, end)) => {
220                                    framed_scope_lines = self.capture_framed_debugger_output(
221                                        &begin,
222                                        &end,
223                                        DEBUGGER_QUERY_WAIT_MS * 8,
224                                    );
225                                }
226                                Err(error) => {
227                                    tracing::warn!(%error, "Failed to send framed variables command, falling back");
228                                    let cmd = format!("V {} ::\n", scope_frame_id);
229                                    let _ = stdin.write_all(cmd.as_bytes());
230                                    let _ = stdin.flush();
231                                }
232                            }
233                        }
234                    }
235                    Some(ScopeKind::Globals) => {
236                        if let Some(stdin) = session.process.stdin.as_mut() {
237                            let commands = vec![format!("V {} *", scope_frame_id)];
238                            match self.send_framed_debugger_commands(stdin, &commands) {
239                                Ok((begin, end)) => {
240                                    framed_scope_lines = self.capture_framed_debugger_output(
241                                        &begin,
242                                        &end,
243                                        DEBUGGER_QUERY_WAIT_MS * 8,
244                                    );
245                                }
246                                Err(error) => {
247                                    tracing::warn!(%error, "Failed to send framed variables command, falling back");
248                                    let cmd = format!("V {} *\n", scope_frame_id);
249                                    let _ = stdin.write_all(cmd.as_bytes());
250                                    let _ = stdin.flush();
251                                }
252                            }
253                        }
254                    }
255                    None => {
256                        // Non-Scope variablesReference — no framed output to fetch.
257                        // Cache hits were already returned via variable_cache above.
258                        // Stale EvalResult refs short-circuit to an empty response
259                        // before reaching this branch (see the early return above).
260                        // A stale Child ref on cache miss silently produces an empty
261                        // list here; that gap is tracked in issue #1445.
262                    }
263                }
264
265                let (full_roots, child_cache) = if let Some(lines) = framed_scope_lines.as_ref() {
266                    let (framed_vars, framed_child_cache) =
267                        Self::parse_scope_variables_from_lines(lines, variables_ref, 0, 1024);
268                    if framed_vars.is_empty() {
269                        Self::wait_for_debugger_output_window(DEBUGGER_QUERY_WAIT_MS as u32);
270                        self.parse_scope_variables_from_output(variables_ref, 0, 1024)
271                    } else {
272                        (framed_vars, framed_child_cache)
273                    }
274                } else {
275                    Self::wait_for_debugger_output_window(DEBUGGER_QUERY_WAIT_MS as u32);
276                    self.parse_scope_variables_from_output(variables_ref, 0, 1024)
277                };
278
279                parsed_from_output = slice_variables(&full_roots, start, count);
280                parsed_full_roots = full_roots;
281                parsed_child_cache = child_cache;
282            }
283        } else {
284            let (full_roots, _child_cache) =
285                self.parse_scope_variables_from_output(variables_ref, 0, 1024);
286            parsed_from_output = slice_variables(&full_roots, start, count);
287        }
288
289        // Capture total count before pagination (pre-slice length) for the DAP totalVariables
290        // field. Priority: fresh parse (parsed_full_roots) > cache hit (cached_total) > unknown.
291        // totalVariables is only emitted when we have a reliable full count; it is omitted
292        // (not null) otherwise, per the DAP spec's optional-field semantics.
293        let total_variables: Option<i64> = if !parsed_full_roots.is_empty() {
294            // Fresh parse: total is the full root list length before pagination.
295            Some(parsed_full_roots.len() as i64)
296        } else {
297            // Cache hit (the cache stores the original full list, so root_count is reliable)
298            // maps to Some; the fallback/unknown path stays None and omits the field.
299            cached_total.map(|n| n as i64)
300        };
301
302        let variables = if parsed_from_output.is_empty() {
303            Self::fallback_scope_variables(variables_ref, start, count)
304        } else {
305            parsed_from_output
306        };
307
308        // Cache parsed roots and generated child references for expansion/paging requests.
309        if !used_session_cache
310            && !parsed_full_roots.is_empty()
311            && let Some(ref mut session) = *lock_or_recover(&self.session, "debug_adapter.session")
312        {
313            session.variable_cache.upsert(
314                variables_ref,
315                VariableCacheKind::Root,
316                parsed_full_roots,
317            );
318            for (reference, children) in parsed_child_cache {
319                session.variable_cache.upsert(reference, VariableCacheKind::Child, children);
320            }
321            let _ = session.variable_cache.get_page(variables_ref, start, count);
322        }
323
324        // Build response body. totalVariables is optional per DAP spec — omit the field
325        // entirely when the value is not known, rather than emitting `null`.  The json!()
326        // macro serializes Option::None as JSON null (not field-absent), so we build the
327        // body with the field only when we have a reliable count.
328        let mut body = json!({ "variables": variables });
329        if let Some(total) = total_variables {
330            body["totalVariables"] = json!(total);
331        }
332
333        DapMessage::Response {
334            seq,
335            request_seq,
336            success: true,
337            command: "variables".to_string(),
338            body: Some(body),
339            message: None,
340        }
341    }
342
343    /// Handle setVariable request
344    pub(super) fn handle_set_variable(
345        &self,
346        seq: i64,
347        request_seq: i64,
348        arguments: Option<Value>,
349    ) -> DapMessage {
350        let args: SetVariableArguments =
351            match arguments.and_then(|v| serde_json::from_value(v).ok()) {
352                Some(a) => a,
353                None => {
354                    return DapMessage::Response {
355                        seq,
356                        request_seq,
357                        success: false,
358                        command: "setVariable".to_string(),
359                        body: None,
360                        message: Some("Missing arguments".to_string()),
361                    };
362                }
363            };
364
365        let variables_ref = args.variables_reference;
366        if variables_ref <= 0 {
367            return DapMessage::Response {
368                seq,
369                request_seq,
370                success: false,
371                command: "setVariable".to_string(),
372                body: None,
373                message: Some("Missing variablesReference".to_string()),
374            };
375        }
376
377        let name = args.name.trim().to_string();
378        let value = args.value.trim().to_string();
379        let name = name.as_str();
380        let value = value.as_str();
381
382        if name.is_empty() {
383            return DapMessage::Response {
384                seq,
385                request_seq,
386                success: false,
387                command: "setVariable".to_string(),
388                body: None,
389                message: Some("Missing variable name".to_string()),
390            };
391        }
392
393        if value.is_empty() {
394            return DapMessage::Response {
395                seq,
396                request_seq,
397                success: false,
398                command: "setVariable".to_string(),
399                body: None,
400                message: Some("Missing variable value".to_string()),
401            };
402        }
403
404        if name.contains('\n')
405            || name.contains('\r')
406            || value.contains('\n')
407            || value.contains('\r')
408        {
409            return DapMessage::Response {
410                seq,
411                request_seq,
412                success: false,
413                command: "setVariable".to_string(),
414                body: None,
415                message: Some("Variable name/value cannot contain newlines".to_string()),
416            };
417        }
418
419        if !is_valid_set_variable_name(name) {
420            return DapMessage::Response {
421                seq,
422                request_seq,
423                success: false,
424                command: "setVariable".to_string(),
425                body: None,
426                message: Some(format!(
427                    "Invalid variable name `{name}` for setVariable (expected Perl sigil-prefixed variable)"
428                )),
429            };
430        }
431
432        if contains_unquoted_statement_separator(value) {
433            return DapMessage::Response {
434                seq,
435                request_seq,
436                success: false,
437                command: "setVariable".to_string(),
438                body: None,
439                message: Some(
440                    "setVariable: unsafe value rejected: statement separators are not allowed"
441                        .to_string(),
442                ),
443            };
444        }
445
446        if !is_safe_set_variable_value(value) {
447            return DapMessage::Response {
448                seq,
449                request_seq,
450                success: false,
451                command: "setVariable".to_string(),
452                body: None,
453                message: Some(
454                    "setVariable: unsafe value rejected: only literal or simple variable-reference values are allowed"
455                        .to_string(),
456                ),
457            };
458        }
459
460        let output_frame_markers = if let Some(ref mut session) =
461            *lock_or_recover(&self.session, "debug_adapter.session")
462        {
463            if let Some(stdin) = session.process.stdin.as_mut() {
464                // Frame assignment + read-back so output parsing is deterministic.
465                let commands = vec![format!("p {name} = {value}"), format!("p {name}")];
466                match self.send_framed_debugger_commands(stdin, &commands) {
467                    Ok(markers) => Some(markers),
468                    Err(error) => {
469                        return DapMessage::Response {
470                            seq,
471                            request_seq,
472                            success: false,
473                            command: "setVariable".to_string(),
474                            body: None,
475                            message: Some(format!("Failed to send setVariable command: {error}")),
476                        };
477                    }
478                }
479            } else {
480                return DapMessage::Response {
481                    seq,
482                    request_seq,
483                    success: false,
484                    command: "setVariable".to_string(),
485                    body: None,
486                    message: Some("No debugger session active".to_string()),
487                };
488            }
489        } else if let Some(pid) = *lock_or_recover(&self.attached_pid, "debug_adapter.attached_pid")
490        {
491            return DapMessage::Response {
492                seq,
493                request_seq,
494                success: false,
495                command: "setVariable".to_string(),
496                body: None,
497                message: Some(format!(
498                    "setVariable is unavailable for processId attach (PID {pid}) without an active debugger transport"
499                )),
500            };
501        } else {
502            return DapMessage::Response {
503                seq,
504                request_seq,
505                success: false,
506                command: "setVariable".to_string(),
507                body: None,
508                message: Some("No debugger session".to_string()),
509            };
510        };
511
512        let parsed = output_frame_markers
513            .as_ref()
514            .and_then(|(begin, end)| {
515                self.capture_framed_debugger_output(begin, end, DEBUGGER_QUERY_WAIT_MS * 8)
516            })
517            .and_then(|lines| Self::parse_evaluate_result_from_lines(&lines, "", true));
518
519        let Some((rendered_value, rendered_type)) = parsed else {
520            return DapMessage::Response {
521                seq,
522                request_seq,
523                success: false,
524                command: "setVariable".to_string(),
525                body: None,
526                message: Some(format!(
527                    "setVariable read-back for `{name}` produced no parseable output"
528                )),
529            };
530        };
531
532        let variables_reference =
533            self.allocate_evaluate_result_ref(name, &rendered_value, &rendered_type);
534        let set_var_body = SetVariableResponseBody {
535            value: rendered_value,
536            type_: Some(rendered_type),
537            variables_reference,
538        };
539
540        DapMessage::Response {
541            seq,
542            request_seq,
543            success: true,
544            command: "setVariable".to_string(),
545            body: serde_json::to_value(&set_var_body).ok(),
546            message: None,
547        }
548    }
549}
550
551fn contains_unquoted_statement_separator(value: &str) -> bool {
552    let mut in_single_quote = false;
553    let mut in_double_quote = false;
554    let mut escaped = false;
555
556    for ch in value.chars() {
557        if escaped {
558            escaped = false;
559            continue;
560        }
561
562        if ch == '\\' {
563            escaped = true;
564            continue;
565        }
566
567        match ch {
568            '\'' if !in_double_quote => in_single_quote = !in_single_quote,
569            '"' if !in_single_quote => in_double_quote = !in_double_quote,
570            ';' if !in_single_quote && !in_double_quote => return true,
571            _ => {}
572        }
573    }
574
575    false
576}
577
578fn is_safe_set_variable_value(value: &str) -> bool {
579    let value = value.trim();
580    value == "undef"
581        || is_quoted_literal(value)
582        || is_numeric_literal(value)
583        || is_valid_set_variable_name(value)
584}
585
586fn is_quoted_literal(value: &str) -> bool {
587    let Some(quote) = value.chars().next().filter(|ch| *ch == '\'' || *ch == '"') else {
588        return false;
589    };
590
591    let mut escaped = false;
592    for (idx, ch) in value.char_indices().skip(1) {
593        if escaped {
594            escaped = false;
595            continue;
596        }
597
598        if ch == '\\' {
599            escaped = true;
600            continue;
601        }
602
603        if ch == quote {
604            return idx + ch.len_utf8() == value.len();
605        }
606    }
607
608    false
609}
610
611fn is_numeric_literal(value: &str) -> bool {
612    let normalized: String = value.chars().filter(|ch| *ch != '_').collect();
613    let has_digit = normalized.chars().any(|ch| ch.is_ascii_digit());
614    let allowed_chars = normalized
615        .chars()
616        .all(|ch| ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E'));
617
618    has_digit && allowed_chars && normalized.parse::<f64>().is_ok()
619}
620
621// ---------------------------------------------------------------------------
622// Hazard-class invariant tests (inline lib tests for patch coverage)
623//
624// These cover three hazard classes from SPEC_UPDATE_CHECKLIST §8:
625//   1. Protocol-safety  — invalid/unknown/stale ref → success=true, variables=[]
626//   2. Bounds/overflow  — extreme i64 values → checked, no panic/wrap
627//   3. ID/ref-space     — eval_ref range (1_000_000+, from #1219) passes through
628//                         the invalid-ref guard without being misrouted/rejected
629// ---------------------------------------------------------------------------
630
631#[cfg(test)]
632mod hazard_invariant_tests {
633    use super::*;
634    use crate::debug_adapter::DebugAdapter;
635    use serde_json::json;
636
637    fn adapter() -> DebugAdapter {
638        DebugAdapter::new()
639    }
640
641    fn variables_body_is_empty(adapter: &mut DebugAdapter, variables_ref: i64) -> bool {
642        let msg = adapter.handle_request(
643            1,
644            "variables",
645            Some(json!({ "variablesReference": variables_ref })),
646        );
647        match msg {
648            DapMessage::Response { success, body, .. } => {
649                if !success {
650                    return false;
651                }
652                let vars = body
653                    .as_ref()
654                    .and_then(|b| b.get("variables"))
655                    .and_then(|v| v.as_array())
656                    .map(|a| a.len())
657                    .unwrap_or(usize::MAX);
658                vars == 0
659            }
660            _ => false,
661        }
662    }
663
664    fn variables_success(adapter: &mut DebugAdapter, variables_ref: i64) -> bool {
665        match adapter.handle_request(
666            1,
667            "variables",
668            Some(json!({ "variablesReference": variables_ref })),
669        ) {
670            DapMessage::Response { success, .. } => success,
671            _ => false,
672        }
673    }
674
675    // --- Protocol-safety: ref=0 → empty ---
676    #[test]
677    fn ref_zero_is_protocol_safe_empty() {
678        let mut a = adapter();
679        assert!(variables_body_is_empty(&mut a, 0), "ref=0 must return empty variables array");
680    }
681
682    // --- Protocol-safety: ref=-1 → empty (negative refs are invalid) ---
683    #[test]
684    fn ref_negative_is_protocol_safe_empty() {
685        let mut a = adapter();
686        for bad in [-1_i64, -100, i32::MIN as i64, i64::MIN] {
687            assert!(
688                variables_body_is_empty(&mut a, bad),
689                "ref={bad} (negative) must return empty variables array"
690            );
691        }
692    }
693
694    // --- Bounds/overflow: i64::MAX → no panic, returns empty (> i32::MAX rejected) ---
695    #[test]
696    fn ref_i64_max_no_panic_returns_empty() {
697        let mut a = adapter();
698        // i64::MAX saturates to i32::MAX under i64_to_i32_saturating; raw-i64 check rejects it first.
699        assert!(
700            variables_body_is_empty(&mut a, i64::MAX),
701            "ref=i64::MAX must return empty (out-of-i32-range guard)"
702        );
703        // Just above i32::MAX is also rejected.
704        assert!(
705            variables_body_is_empty(&mut a, i32::MAX as i64 + 1),
706            "ref=i32::MAX+1 must return empty (out-of-range)"
707        );
708    }
709
710    // --- Bounds/overflow: i32::MAX is in-range (allowed through, no panic) ---
711    #[test]
712    fn ref_i32_max_no_panic() {
713        let mut a = adapter();
714        // i32::MAX passes the raw-i64 guard and goes through normal path without session.
715        // It must not panic — success=true is required.
716        assert!(
717            variables_success(&mut a, i32::MAX as i64),
718            "ref=i32::MAX must succeed (in-range, no session → honest empty)"
719        );
720    }
721
722    // --- ID/ref-space: eval_ref range (1_000_000+) is NOT rejected by invalid-ref guard ---
723    //
724    // #1219 allocates eval refs starting at 1_000_000 (base 1_000_000 + counter).
725    // Those refs must pass through the invalid-ref check (0 < 1_000_000 <= i32::MAX) and
726    // reach the normal cache-lookup path. Without a session they return honest-empty.
727    // Crucially: they must NOT be misrouted as "invalid" just because they look large.
728    #[test]
729    fn eval_ref_range_passes_invalid_ref_guard() {
730        let mut a = adapter();
731        // These are valid eval refs from #1219 — not rejected, return success=true.
732        for eval_ref in [1_000_000_i64, 1_000_001, 1_000_100, 1_999_999] {
733            assert!(
734                variables_success(&mut a, eval_ref),
735                "eval_ref={eval_ref} (from #1219 range) must not be rejected by invalid-ref guard"
736            );
737        }
738    }
739
740    // --- ID/ref-space: scope refs (frame_id*10 + {1,2,3}) coexist with eval refs ---
741    //
742    // Scope ref range: frame_id in [0, ~200M] * 10 + {1,2,3} — well below 1_000_000 for
743    // reasonable frame counts (< 100_000 frames → refs < 1_000_003). Eval refs start at
744    // 1_000_000. Both ranges must produce success=true without session (honest empty).
745    #[test]
746    fn scope_ref_and_eval_ref_ranges_do_not_collide_for_small_frame_ids() {
747        let mut a = adapter();
748        // Scope refs for frame_id <=99_999 are in [1, 999_993]; eval refs from #1219
749        // start at 1_000_000 — no overlap. Encoding invariant documented in #901/#1219.
750        let max_scope_ref: i64 = 99_999 * 10 + 3; // = 999_993
751        let min_eval_ref: i64 = 1_000_000;
752        // Verify both values PASS the invalid-ref guard ([1, i32::MAX]) and return success.
753        // This tests actual guard behavior — the range-non-collision is a precondition
754        // documented above, not an assertion on the code under test.
755        assert!(
756            variables_success(&mut a, max_scope_ref),
757            "max scope ref ({max_scope_ref}) must pass invalid-ref guard and succeed"
758        );
759        assert!(
760            variables_success(&mut a, min_eval_ref),
761            "min eval ref ({min_eval_ref}) must pass invalid-ref guard and succeed"
762        );
763    }
764
765    // --- Protocol-safety: never-allocated ref (valid range, but unknown to cache) → no crash ---
766    #[test]
767    fn never_allocated_ref_in_valid_range_no_crash() {
768        let mut a = adapter();
769        // A ref that looks like a scope ref but was never actually allocated — no session,
770        // so it goes through the no-session path. Must not panic.
771        for stale in [11_i64, 12, 13, 21, 22, 23, 999] {
772            assert!(
773                variables_success(&mut a, stale),
774                "never-allocated scope ref={stale} must succeed (no crash, honest empty)"
775            );
776        }
777    }
778
779    // --- Fix #1338: stale EvalResult ref with Stopped session -> early short-circuit ---
780    //
781    // This lib test covers the new early-return branch in handle_variables() added by
782    // fix #1338 (cache-miss branch, EvalResult short-circuit in variables.rs).
783    //
784    // Path exercised:
785    //   1. Session IS Stopped -> passes the Running-state guard (lines 73-92 pre-fix)
786    //   2. Cache miss for eval_ref wire -> enters else branch
787    //   3. decode yields EvalResult -> short-circuit, return honest empty immediately
788    //
789    // Without the fix, control falls through to parse_scope_variables_from_output
790    // (75ms detour via wait_for_debugger_output_window) before returning empty via
791    // fallback_scope_variables. The short-circuit removes the delay and bogus routing.
792    //
793    // Skip when perl is not on PATH (seed_stopped_session_with_frames_for_test
794    // spawns perl -e 1 as a no-op child process).
795    #[test]
796    fn fix_1338_stale_eval_ref_stopped_session_short_circuits_to_honest_empty() {
797        // Skip if perl is not available on PATH.
798        if std::process::Command::new("perl").arg("-e").arg("1").output().is_err() {
799            return;
800        }
801        let mut a = adapter();
802        // Seed a Stopped session so the Running-state guard does not trigger.
803        // This exercises the cache-miss path and the new EvalResult short-circuit.
804        a.seed_stopped_session_with_frames_for_test(vec![]);
805
806        // EvalResult band wire values: stale after resume (not in cache).
807        for eval_ref_wire in [1_000_000_i64, 1_000_001, 1_000_003, 1_100_000] {
808            assert!(
809                variables_body_is_empty(&mut a, eval_ref_wire),
810                "fix #1338: stopped session + stale eval_ref={eval_ref_wire} must return honest empty"
811            );
812        }
813    }
814
815    // --- Guard test: cached EvalResult is NOT short-circuited by the fix #1338 early return ---
816    //
817    // This is the scoping guard for the fix: a VALID EvalResult ref that IS in the cache
818    // must be served via the cache-hit path (line 102) and return its children — it must
819    // NOT be swallowed by the early-return short-circuit (which fires only on cache miss).
820    //
821    // Without this guard, a regression could incorrectly apply the early return to ALL
822    // EvalResult refs (cached or not), causing legitimate variable expansion to return
823    // empty. This test would fail immediately in that case.
824    //
825    // Skip when perl is not on PATH.
826    #[test]
827    fn fix_1338_cached_eval_result_is_served_not_short_circuited() {
828        // Skip if perl is not available on PATH.
829        if std::process::Command::new("perl").arg("-e").arg("1").output().is_err() {
830            return;
831        }
832        use crate::debug_adapter::var_ref::VariableReference;
833        use crate::types::Variable;
834
835        let mut a = adapter();
836        a.seed_stopped_session_with_frames_for_test(vec![]);
837
838        // An EvalResult wire value that IS in cache (simulates a fresh evaluate result
839        // before resume — the client holds the ref and sends a variables request while
840        // the session is still stopped at the same breakpoint).
841        let eval_ref_wire: i32 =
842            VariableReference::EvalResult { counter: 42 }.encode().expect("counter=42 is valid");
843        assert!(
844            (1_000_000..=1_999_999_999).contains(&eval_ref_wire),
845            "setup: must be in EvalResult band"
846        );
847
848        // Seed a cached entry so the cache-hit path fires.
849        let cached_var = Variable {
850            name: "".to_string(),
851            value: "42".to_string(),
852            type_: Some("SCALAR".to_string()),
853            variables_reference: 0,
854            named_variables: None,
855            indexed_variables: None,
856        };
857        a.seed_eval_result_cache_for_test(eval_ref_wire, vec![cached_var]);
858
859        // Must return the cached children (non-empty), NOT the early-return empty.
860        // If the early return incorrectly fired here, this assertion would fail.
861        let msg = a.handle_request(
862            1,
863            "variables",
864            Some(serde_json::json!({ "variablesReference": eval_ref_wire as i64 })),
865        );
866        match msg {
867            DapMessage::Response { success, body, .. } => {
868                assert!(success, "cached EvalResult must succeed");
869                let vars = body
870                    .as_ref()
871                    .and_then(|b| b.get("variables"))
872                    .and_then(|v| v.as_array())
873                    .map(|a| a.len())
874                    .unwrap_or(0);
875                assert_eq!(
876                    vars, 1,
877                    "cached EvalResult must return its 1 cached child; got {vars} (early return was applied incorrectly)"
878                );
879            }
880            other => panic!("expected Response, got: {other:?}"),
881        }
882    }
883}