Skip to content

Commit 9986cde

Browse files
authored
feat(debugger): inspect caller frame bindings (#997)
1 parent 3a43454 commit 9986cde

9 files changed

Lines changed: 382 additions & 8 deletions

debug_session_benchmark_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,55 @@ RETURN FOR i IN 1..100
4949
}
5050
}
5151
}
52+
53+
func BenchmarkDebugSessionPausedCallerFrameInspection(b *testing.B) {
54+
engine, err := New()
55+
if err != nil {
56+
b.Fatal(err)
57+
}
58+
defer engine.Close()
59+
60+
plan, err := engine.CompileDebug(context.Background(), source.New("caller.fql", `LET base = 1
61+
FUNC outer(p) {
62+
LET carried = base + p
63+
FUNC inner(q) {
64+
RETURN carried + q
65+
}
66+
LET result = inner(3)
67+
RETURN result
68+
}
69+
RETURN outer(2)`))
70+
if err != nil {
71+
b.Fatal(err)
72+
}
73+
defer plan.Close()
74+
75+
session, err := plan.NewDebugSession(context.Background())
76+
if err != nil {
77+
b.Fatal(err)
78+
}
79+
defer session.Close()
80+
81+
if _, err := session.SetBreakpoint("caller.fql", 5); err != nil {
82+
b.Fatal(err)
83+
}
84+
if _, err := session.Start(context.Background()); err != nil {
85+
b.Fatal(err)
86+
}
87+
if _, err := session.Continue(context.Background()); err != nil {
88+
b.Fatal(err)
89+
}
90+
91+
b.ReportAllocs()
92+
b.ResetTimer()
93+
94+
for b.Loop() {
95+
locals, localsErr := session.FrameLocals(2)
96+
if localsErr != nil {
97+
b.Fatal(localsErr)
98+
}
99+
if len(locals) == 0 {
100+
b.Fatal("caller frame has no locals")
101+
}
102+
}
103+
}

debug_session_udf_resolution_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package ferret
22

33
import (
44
"context"
5+
"errors"
56
"strings"
67
"testing"
78

9+
"github.com/MontFerret/ferret/v2/pkg/debugger"
810
"github.com/MontFerret/ferret/v2/pkg/diagnostics"
11+
"github.com/MontFerret/ferret/v2/pkg/runtime"
912
"github.com/MontFerret/ferret/v2/pkg/source"
1013
)
1114

@@ -303,4 +306,140 @@ RETURN y`
303306
if !strings.Contains(formatted, "udf-error.fql:4") || !strings.Contains(formatted, "RETURN b / 0") {
304307
t.Fatalf("expected UDF error location, got:\n%s", formatted)
305308
}
309+
assertFrameValues(t, session, 1, map[string]string{"x": "1"})
310+
value, err := session.EvaluateFrame(context.Background(), 1, "x + 1")
311+
if err != nil || value.Display != "2" {
312+
t.Fatalf("unexpected runtime-error caller evaluation: %#v, %v", value, err)
313+
}
314+
}
315+
316+
func TestDebugSessionCallerFramesExposeBindingsCellsAndParameters(t *testing.T) {
317+
engine, err := New()
318+
if err != nil {
319+
t.Fatal(err)
320+
}
321+
defer engine.Close()
322+
323+
query := `VAR shared = 1
324+
FUNC outer(p) {
325+
VAR x = p + shared - shared
326+
FUNC inner(q) {
327+
shared = shared + q
328+
LET x = shared + q
329+
RETURN x
330+
}
331+
LET result = inner(3)
332+
RETURN result
333+
}
334+
LET box = {value: 10}
335+
LET x = 10
336+
RETURN outer(2) + x + @input + box.value - 10`
337+
plan, err := engine.CompileDebug(context.Background(), source.New("caller-frames.fql", query))
338+
if err != nil {
339+
t.Fatal(err)
340+
}
341+
defer plan.Close()
342+
343+
session, err := plan.NewDebugSession(context.Background(), WithSessionParam("input", 5))
344+
if err != nil {
345+
t.Fatal(err)
346+
}
347+
defer session.Close()
348+
349+
breakpoint, err := session.SetBreakpoint("caller-frames.fql", 5)
350+
if err != nil {
351+
t.Fatal(err)
352+
}
353+
if !breakpoint.Bound || breakpoint.Line != 5 {
354+
t.Fatalf("unexpected inner breakpoint: %#v", breakpoint)
355+
}
356+
357+
if _, err := session.Start(context.Background()); err != nil {
358+
t.Fatal(err)
359+
}
360+
event, err := session.Continue(context.Background())
361+
if err != nil {
362+
t.Fatal(err)
363+
}
364+
if event.Reason != DebugReasonBreakpoint || event.Location.Line != 5 || event.Depth != 2 {
365+
t.Fatalf("unexpected nested stop: %#v", event)
366+
}
367+
368+
frames, err := session.Frames()
369+
if err != nil {
370+
t.Fatal(err)
371+
}
372+
if len(frames) != 3 || frames[0].Name != "inner" || frames[1].Name != "outer" || frames[2].Name != "<main>" {
373+
t.Fatalf("unexpected nested frames: %#v", frames)
374+
}
375+
376+
assertFrameValues(t, session, 0, map[string]string{"q": "3", "shared": "1", "@input": "5"})
377+
assertFrameValues(t, session, 1, map[string]string{"p": "2", "x": "2", "shared": "1", "@input": "5"})
378+
assertFrameValues(t, session, 2, map[string]string{"x": "10", "shared": "1", "@input": "5"})
379+
380+
callerLocals, err := session.FrameLocals(2)
381+
if err != nil {
382+
t.Fatal(err)
383+
}
384+
var callerReference debugger.ValueReference
385+
for _, local := range callerLocals {
386+
if local.Name == "box" {
387+
callerReference = local.Value.Reference
388+
}
389+
}
390+
if !callerReference.Valid() {
391+
t.Fatalf("expected expandable caller value: %#v", callerLocals)
392+
}
393+
394+
value, err := session.EvaluateFrame(context.Background(), 2, "x + shared + @input")
395+
if err != nil || value.Display != "16" {
396+
t.Fatalf("unexpected caller evaluation: %#v, %v", value, err)
397+
}
398+
if _, err := session.FrameLocals(-1); !errors.Is(err, runtime.ErrInvalidArgument) {
399+
t.Fatalf("expected negative frame rejection, got %v", err)
400+
}
401+
if _, err := session.FrameLocals(len(frames)); !errors.Is(err, runtime.ErrNotFound) {
402+
t.Fatalf("expected missing frame rejection, got %v", err)
403+
}
404+
405+
event, err = session.Step(context.Background())
406+
if err != nil {
407+
t.Fatal(err)
408+
}
409+
if event.Location.Line != 6 {
410+
t.Fatalf("unexpected mutable-cell step: %#v", event)
411+
}
412+
if _, err := session.Variables(callerReference); !errors.Is(err, runtime.ErrNotFound) {
413+
t.Fatalf("expected caller reference to become stale on resume, got %v", err)
414+
}
415+
assertFrameValues(t, session, 0, map[string]string{"shared": "4", "@input": "5"})
416+
assertFrameValues(t, session, 1, map[string]string{"x": "2", "shared": "4", "@input": "5"})
417+
assertFrameValues(t, session, 2, map[string]string{"x": "10", "shared": "4", "@input": "5"})
418+
419+
value, err = session.EvaluateFrame(context.Background(), 1, "x + shared + @input")
420+
if err != nil || value.Display != "11" {
421+
t.Fatalf("unexpected mutable caller evaluation: %#v, %v", value, err)
422+
}
423+
}
424+
425+
func assertFrameValues(t *testing.T, session interface {
426+
FrameLocals(int) ([]debugger.Variable, error)
427+
}, frame int, expected map[string]string) {
428+
t.Helper()
429+
430+
locals, err := session.FrameLocals(frame)
431+
if err != nil {
432+
t.Fatal(err)
433+
}
434+
435+
actual := make(map[string]string, len(locals))
436+
for _, local := range locals {
437+
actual[local.Name] = local.Value.Display
438+
}
439+
440+
for name, value := range expected {
441+
if actual[name] != value {
442+
t.Fatalf("frame %d binding %q: expected %q, got %q in %#v", frame, name, value, actual[name], locals)
443+
}
444+
}
306445
}

pkg/debugger/session.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,12 @@ func (s *Session) Frames() ([]Frame, error) {
316316

317317
// Locals returns the visible top-frame locals followed by bound parameters.
318318
func (s *Session) Locals() ([]Variable, error) {
319+
return s.FrameLocals(0)
320+
}
321+
322+
// FrameLocals returns the visible locals and bound parameters for one paused
323+
// frame. Frame indexes follow Frames: zero is the current frame.
324+
func (s *Session) FrameLocals(frame int) ([]Variable, error) {
319325
if err := s.lockCommand(); err != nil {
320326
return nil, err
321327
}
@@ -324,7 +330,7 @@ func (s *Session) Locals() ([]Variable, error) {
324330
if err := s.ensureOpen(); err != nil {
325331
return nil, err
326332
}
327-
locals, err := s.execution.Locals()
333+
locals, err := s.frameLocals(frame)
328334

329335
if err != nil {
330336
return nil, err
@@ -349,6 +355,18 @@ func (s *Session) Locals() ([]Variable, error) {
349355
return out, nil
350356
}
351357

358+
func (s *Session) frameLocals(frame int) ([]vm.DebugLocal, error) {
359+
if inspector, ok := s.execution.(vm.DebugFrameInspector); ok {
360+
return inspector.FrameLocals(frame)
361+
}
362+
363+
if frame != 0 {
364+
return nil, runtime.Error(runtime.ErrInvalidOperation, "debug execution does not support caller frame inspection")
365+
}
366+
367+
return s.execution.Locals()
368+
}
369+
352370
// Variables returns the child variables for one expandable debugger value from
353371
// the current paused state.
354372
func (s *Session) Variables(reference ValueReference) ([]Variable, error) {
@@ -408,6 +426,11 @@ func (s *Session) Variables(reference ValueReference) ([]Variable, error) {
408426
// rejects calls, queries, mutation, async/event behavior, and full collection
409427
// execution.
410428
func (s *Session) Evaluate(ctx context.Context, expression string) (Value, error) {
429+
return s.EvaluateFrame(ctx, 0, expression)
430+
}
431+
432+
// EvaluateFrame evaluates an expression against one paused frame.
433+
func (s *Session) EvaluateFrame(ctx context.Context, frame int, expression string) (Value, error) {
411434
if err := s.lockCommand(); err != nil {
412435
return Value{}, err
413436
}
@@ -416,7 +439,7 @@ func (s *Session) Evaluate(ctx context.Context, expression string) (Value, error
416439
if err := s.ensureOpen(); err != nil {
417440
return Value{}, err
418441
}
419-
locals, err := s.execution.Locals()
442+
locals, err := s.frameLocals(frame)
420443

421444
if err != nil {
422445
return Value{}, err

pkg/debugger/session_concurrency_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ func TestSessionCloseClearsReferencesCreatedByActiveInspection(t *testing.T) {
161161

162162
localsDone := make(chan error, 1)
163163
go func() {
164-
_, err := session.Locals()
164+
_, err := session.FrameLocals(0)
165165
localsDone <- err
166166
}()
167167
waitForSignal(t, inspectStarted, "value inspection")

pkg/debugger/session_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ func TestSessionUsesInterfacesForBreakpointsEvaluationAndLifecycle(t *testing.T)
6666
if value.Display != "2" || values.typeCalls == 0 || values.debugInfoCalls == 0 {
6767
t.Fatalf("unexpected evaluated value: %#v", value)
6868
}
69+
if _, err := session.FrameLocals(1); !errors.Is(err, runtime.ErrInvalidOperation) {
70+
t.Fatalf("expected legacy execution to reject caller inspection, got %v", err)
71+
}
72+
value, err = session.EvaluateFrame(context.Background(), 0, "x + 2")
73+
if err != nil || value.Display != "3" {
74+
t.Fatalf("unexpected legacy top-frame evaluation: %#v, %v", value, err)
75+
}
6976
if err := session.Close(); err != nil {
7077
t.Fatal(err)
7178
}

0 commit comments

Comments
 (0)