diff --git a/modules/xdebug/breakpoints.go b/modules/xdebug/breakpoints.go index 06c3c9ab..8f8ce9d9 100644 --- a/modules/xdebug/breakpoints.go +++ b/modules/xdebug/breakpoints.go @@ -34,7 +34,7 @@ type SavedBreakpoint struct { Type string `json:"type"` // line or exception File string `json:"file,omitempty"` // file:// URI as PHP sees it (line) Line int `json:"line,omitempty"` // line - Exception string `json:"exception,omitempty"` // class name, e.g. \Throwable (exception) + Exception string `json:"exception,omitempty"` // class name as PHP holds it, e.g. Throwable (exception) Condition string `json:"condition,omitempty"` // PHP expression; sent only with allow_eval Enabled bool `json:"enabled"` Source string `json:"source"` // ui or mcp @@ -50,7 +50,9 @@ func (b *SavedBreakpoint) normalize() error { } } b.File = fileURI(strings.TrimSpace(b.File)) - b.Exception = strings.TrimSpace(b.Exception) + // Xdebug matches the class name as PHP holds it, with no leading \; + // "\RuntimeException" would be accepted and never fire. + b.Exception = strings.TrimPrefix(strings.TrimSpace(b.Exception), `\`) switch b.Type { case BPLine: if b.File == "" || b.Line < 1 { @@ -62,7 +64,7 @@ func (b *SavedBreakpoint) normalize() error { b.Exception = "" case BPException: if b.Exception == "" { - return errors.New("an exception breakpoint needs exception (a class name, e.g. \\Throwable)") + return errors.New("an exception breakpoint needs exception (a class name, e.g. Throwable)") } b.File, b.Line = "", 0 default: diff --git a/modules/xdebug/breakpoints_test.go b/modules/xdebug/breakpoints_test.go index 4382bfbe..a9afc820 100644 --- a/modules/xdebug/breakpoints_test.go +++ b/modules/xdebug/breakpoints_test.go @@ -125,7 +125,7 @@ func TestBreakpoints_SentAtStartWithState(t *testing.T) { waitFor(t, "break", s.Paused) sets := be.commands("breakpoint_set") - if len(sets) != 2 || !strings.Contains(sets[0], "-t line -f file:///app/src/User.php -n 20") || !strings.Contains(sets[1], `-t exception -x "\\Throwable"`) { + if len(sets) != 2 || !strings.Contains(sets[0], "-t line -f file:///app/src/User.php -n 20") || !strings.Contains(sets[1], `-t exception -x Throwable`) { t.Fatalf("breakpoint_set: %q", sets) } // Breakpoints go out before the script runs. @@ -147,7 +147,7 @@ func TestBreakpoints_SentAtStartWithState(t *testing.T) { for _, want := range []string{ `"id":` + itoa(int(line.ID)) + `,"type":"line","file":"file:///app/src/User.php","line":20,"enabled":true,"source":"ui"`, `"state":"resolved","resolved_line":21,"hit_count":2`, - `"id":` + itoa(int(exc.ID)) + `,"type":"exception","exception":"\\Throwable"`, + `"id":` + itoa(int(exc.ID)) + `,"type":"exception","exception":"Throwable"`, `"id":` + itoa(int(off.ID)) + `,"type":"line","file":"file:///app/src/Off.php","line":3,"enabled":false`, `"state":"disabled"`, `"id":` + itoa(int(cond.ID)), @@ -246,7 +246,7 @@ func TestBreakpoints_API(t *testing.T) { if code, body := a.do(t, "POST", "/api/xdebug/breakpoints", `{"exception":"\\RuntimeException"}`); code != http.StatusCreated || !strings.Contains(body, `"type":"exception"`) { t.Fatalf("exception: %d %s", code, body) } - if code, body := a.do(t, "GET", "/api/xdebug/breakpoints", ""); code != http.StatusOK || !strings.Contains(body, `"exception":"\\RuntimeException"`) { + if code, body := a.do(t, "GET", "/api/xdebug/breakpoints", ""); code != http.StatusOK || !strings.Contains(body, `"exception":"RuntimeException"`) { t.Fatalf("list: %d %s", code, body) } if code, _ := a.do(t, "PATCH", "/api/xdebug/breakpoints/99", `{"enabled":false}`); code != http.StatusNotFound { @@ -538,3 +538,25 @@ func TestBreakpoints_ListResolves(t *testing.T) { t.Fatalf("%+v", bps) } } + +// Xdebug matches an exception breakpoint by the class name as PHP holds it: +// "\RuntimeException" is accepted by the engine and never fires. +func TestBreakpoints_ExceptionLeadingBackslash(t *testing.T) { + for in, want := range map[string]string{ + `\RuntimeException`: `RuntimeException`, + ` \App\Errors\Payment `: `App\Errors\Payment`, + `RuntimeException`: `RuntimeException`, + `*`: `*`, + } { + b := SavedBreakpoint{Exception: in} + if err := b.normalize(); err != nil || b.Type != BPException || b.Exception != want { + t.Errorf("%q: got %q type %q err %v, want %q", in, b.Exception, b.Type, err, want) + } + if cmd := breakpointSetCmd(b); !strings.HasSuffix(cmd, " -x "+quoteArg(want)) { + t.Errorf("%q: command %q", in, cmd) + } + } + if b := (SavedBreakpoint{Exception: `\`}); b.normalize() == nil { + t.Error(`a lone \ must be rejected`) + } +} diff --git a/modules/xdebug/control_mcp.go b/modules/xdebug/control_mcp.go index 62b5a019..34c82232 100644 --- a/modules/xdebug/control_mcp.go +++ b/modules/xdebug/control_mcp.go @@ -224,7 +224,7 @@ func (s *Session) pausedAtOf() time.Time { type bpSetInput struct { File string `json:"file,omitempty" jsonschema:"File as PHP sees it: an absolute path (/var/www/app/src/User.php) or a file:// URI. Paths on the PHP side, not your local checkout, unless they are the same."` Line int `json:"line,omitempty" jsonschema:"Line number (1-based) for a line breakpoint"` - Exception string `json:"exception,omitempty" jsonschema:"Exception class for an exception breakpoint instead of file+line, e.g. \\Throwable or \\App\\Exceptions\\PaymentFailed"` + Exception string `json:"exception,omitempty" jsonschema:"Exception class for an exception breakpoint instead of file+line, e.g. Throwable or App\\Exceptions\\PaymentFailed (a leading \\ is dropped)"` Condition string `json:"condition,omitempty" jsonschema:"PHP expression; stop only when it is true. Needs xdebug.allow_eval."` }