From 9b49462c36f84be6591b3f343d76ac9d54e7db22 Mon Sep 17 00:00:00 2001 From: James Harris Date: Sun, 22 Nov 2020 13:46:28 +1000 Subject: [PATCH] Add `panicx.LocationOfFunc()` and `LocationOfCall()`. --- engine/panicx/linenumber_test.go | 53 ++++++++++++++++++ engine/panicx/location.go | 70 +++++++++++++++++++----- engine/panicx/location_test.go | 35 ++++++++++++ engine/panicx/unexpectedbehavior.go | 48 ++++++++++++++++ engine/panicx/unexpectedbehavior_test.go | 37 +++++++++++++ engine/panicx/unexpectedmessage_test.go | 8 +-- 6 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 engine/panicx/linenumber_test.go create mode 100644 engine/panicx/unexpectedbehavior.go create mode 100644 engine/panicx/unexpectedbehavior_test.go diff --git a/engine/panicx/linenumber_test.go b/engine/panicx/linenumber_test.go new file mode 100644 index 00000000..6e5741e7 --- /dev/null +++ b/engine/panicx/linenumber_test.go @@ -0,0 +1,53 @@ +package panicx_test + +// This file contains definitions used within tests that check for specific line +// numbers. To minimize test disruption edit this file as infrequently as +// possible. +// +// New additions should always be made at the end so that the line numbers of +// existing definitions do not change. The padding below can be removed as +// imports statements added. + +import ( + "github.com/dogmatiq/dogma" + . "github.com/dogmatiq/testkit/engine/panicx" + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding + // import padding +) + +func doNothing() {} +func panicWithUnexpectedMessage() { panic(dogma.UnexpectedMessage) } +func locationOfCallLayer1() Location { return LocationOfCall() } +func locationOfCallLayer2() Location { return locationOfCallLayer1() } diff --git a/engine/panicx/location.go b/engine/panicx/location.go index 4a8581f4..e0c0b95a 100644 --- a/engine/panicx/location.go +++ b/engine/panicx/location.go @@ -3,6 +3,7 @@ package panicx import ( "fmt" "path" + "reflect" "runtime" "strings" ) @@ -39,6 +40,45 @@ func (l Location) String() string { return "" } +// LocationOfFunc returns the location of the definition of fn. +func LocationOfFunc(fn interface{}) Location { + rv := reflect.ValueOf(fn) + if rv.Kind() != reflect.Func { + panic("fn must be a function") + } + + var ( + loc Location + pc = rv.Pointer() + ) + + if fn := runtime.FuncForPC(pc); fn != nil { + loc.Func = fn.Name() + loc.File, loc.Line = fn.FileLine(pc) + } + + return loc +} + +// LocationOfCall returns the location where its caller was called itself. +func LocationOfCall() Location { + var loc Location + + eachFrame( + 2, // skip LocationOfCall() and its caller. + func(fr runtime.Frame) bool { + loc = Location{ + Func: fr.Function, + File: fr.File, + Line: fr.Line, + } + return false + }, + ) + + return loc +} + // LocationOfPanic returns the location of the call to panic() that caused the // stack to start unwinding. // @@ -56,20 +96,22 @@ func LocationOfPanic() Location { foundPanicCall := false eachFrame( + 0, func(fr runtime.Frame) bool { - if fr.Function == "runtime.gopanic" { - // We found the call to runtime.gopanic(), which is the internal - // implementation of panic(). - // - // That means that the next function we find that's NOT in the - // "runtime" package is the function that called panic(). - foundPanicCall = true - return true - } - if strings.HasPrefix(fr.Function, "runtime.") { - // We found some other function within the runtime package, we - // keep looking for some user-land code. + // We found some function within the runtime package, we keep + // looking for some user-land code. + + if fr.Function == "runtime.gopanic" { + // We found the call to runtime.gopanic(), which is the + // internal implementation of panic(). + // + // That means that the next function we find that's NOT in + // the "runtime" package is the function that called + // panic(). + foundPanicCall = true + } + return true } @@ -95,9 +137,9 @@ func LocationOfPanic() Location { } // eachFrame calls fn for each frame on the call stack until fn returns false. -func eachFrame(fn func(fr runtime.Frame) bool) { +func eachFrame(skip int, fn func(fr runtime.Frame) bool) { var pointers [8]uintptr - var skip int + skip += 2 // Always skip runtime.Callers() and eachFrame(). for { count := runtime.Callers(skip, pointers[:]) diff --git a/engine/panicx/location_test.go b/engine/panicx/location_test.go index 79c7f59b..2a2121b5 100644 --- a/engine/panicx/location_test.go +++ b/engine/panicx/location_test.go @@ -5,9 +5,44 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo/extensions/table" . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" ) var _ = Describe("type Location", func() { + Describe("func LocationOfFunc()", func() { + It("returns the expected location", func() { + loc := LocationOfFunc(doNothing) + + Expect(loc).To(MatchAllFields( + Fields{ + "Func": Equal("github.com/dogmatiq/testkit/engine/panicx_test.doNothing"), + "File": HaveSuffix("/engine/panicx/linenumber_test.go"), + "Line": Equal(50), + }, + )) + }) + + It("returns an empty location if the value is not a function", func() { + Expect(func() { + LocationOfFunc("") + }).To(PanicWith("fn must be a function")) + }) + }) + + Describe("func LocationOfCall()", func() { + It("returns the expected location", func() { + loc := locationOfCallLayer2() + + Expect(loc).To(MatchAllFields( + Fields{ + "Func": Equal("github.com/dogmatiq/testkit/engine/panicx_test.locationOfCallLayer2"), + "File": HaveSuffix("/engine/panicx/linenumber_test.go"), + "Line": Equal(53), + }, + )) + }) + }) + Describe("func String()", func() { DescribeTable( "it returns the expectation string", diff --git a/engine/panicx/unexpectedbehavior.go b/engine/panicx/unexpectedbehavior.go new file mode 100644 index 00000000..7b3ce96c --- /dev/null +++ b/engine/panicx/unexpectedbehavior.go @@ -0,0 +1,48 @@ +package panicx + +import ( + "fmt" + + "github.com/dogmatiq/configkit" + "github.com/dogmatiq/dogma" +) + +// UnexpectedBehavior is a panic value that occurs when a handler exhibits some +// behavior that the engine did not expect. +// +// Often this means it has violated the Dogma specification. +type UnexpectedBehavior struct { + // Handler is the non-compliant handler. + Handler configkit.RichHandler + + // Interface is the name of the interface containing the method with the + // unexpected behavior. + Interface string + + // Method is the name of the method that behaved unexpectedly. + Method string + + // Implementation is the value that implements the nominated interface. + Implementation interface{} + + // Message is the message that was being handled at the time, if any. + Message dogma.Message + + // Description is a human-readable description of the behavior. + Description string + + // Location is the engine's best attempt at pinpointing the location of the + // unexpected behavior. + Location Location +} + +func (x UnexpectedBehavior) String() string { + return fmt.Sprintf( + "the '%s' %s message handler behaved unexpectedly in %T.%s(): %s", + x.Handler.Identity().Name, + x.Handler.HandlerType(), + x.Implementation, + x.Method, + x.Description, + ) +} diff --git a/engine/panicx/unexpectedbehavior_test.go b/engine/panicx/unexpectedbehavior_test.go new file mode 100644 index 00000000..ef145fa7 --- /dev/null +++ b/engine/panicx/unexpectedbehavior_test.go @@ -0,0 +1,37 @@ +package panicx_test + +import ( + "github.com/dogmatiq/configkit" + "github.com/dogmatiq/dogma" + . "github.com/dogmatiq/dogma/fixtures" + . "github.com/dogmatiq/testkit/engine/panicx" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("type UnexpectedBehavior", func() { + config := configkit.FromProjection( + &ProjectionMessageHandler{ + ConfigureFunc: func(c dogma.ProjectionConfigurer) { + c.Identity("", "") + c.ConsumesEventType(MessageE{}) + }, + }, + ) + + Describe("func String()", func() { + It("returns a description of the panic", func() { + x := UnexpectedBehavior{ + Handler: config, + Interface: "", + Method: "", + Implementation: config.Handler(), + Description: "", + } + + Expect(x.String()).To(Equal( + "the '' projection message handler behaved unexpectedly in *fixtures.ProjectionMessageHandler.(): ", + )) + }) + }) +}) diff --git a/engine/panicx/unexpectedmessage_test.go b/engine/panicx/unexpectedmessage_test.go index ce24a3ee..7b91899f 100644 --- a/engine/panicx/unexpectedmessage_test.go +++ b/engine/panicx/unexpectedmessage_test.go @@ -109,8 +109,8 @@ var _ = Describe("func EnrichUnexpectedMessage()", func() { "PanicLocation": MatchAllFields( Fields{ "Func": Equal("github.com/dogmatiq/testkit/engine/panicx_test.panicWithUnexpectedMessage"), - "File": HaveSuffix("/engine/panicx/unexpectedmessage_test.go"), - "Line": Equal(123), + "File": HaveSuffix("/engine/panicx/linenumber_test.go"), + "Line": Equal(51), }, ), }, @@ -118,7 +118,3 @@ var _ = Describe("func EnrichUnexpectedMessage()", func() { )) }) }) - -func panicWithUnexpectedMessage() { - panic(dogma.UnexpectedMessage) -}