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}